From 08094cb8b862da6615c8bbed0ff4ee35c105b4a5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 4 Jun 2026 10:05:45 +0200 Subject: [PATCH 001/707] Migrate http config to ui (#171177) Co-authored-by: Martin Hjelmare --- homeassistant/bootstrap.py | 9 +- homeassistant/components/http/__init__.py | 116 +--- homeassistant/components/http/config.py | 321 +++++++++ homeassistant/components/http/const.py | 24 + homeassistant/components/http/strings.json | 12 + .../components/http/websocket_api.py | 85 +++ .../conversation/test_default_agent.py | 2 +- tests/components/http/test_init.py | 655 +++++++++++++++--- tests/conftest.py | 2 +- tests/test_bootstrap.py | 2 +- 10 files changed, 1057 insertions(+), 171 deletions(-) create mode 100644 homeassistant/components/http/config.py create mode 100644 homeassistant/components/http/websocket_api.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 81a9fca6160f..5313392d73a9 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -47,7 +47,7 @@ from .components import ( file_upload as file_upload_pre_import, # noqa: F401 group as group_pre_import, # noqa: F401 history as history_pre_import, # noqa: F401 - http, # not named pre_import since it has requirements + http as http_import, # noqa: F401 - not named pre_import since it has requirements image_upload as image_upload_import, # noqa: F401 - not named pre_import since it has requirements logbook as logbook_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401 @@ -414,12 +414,7 @@ async def async_setup_hass( _LOGGER.info("Starting in recovery mode") hass.config.recovery_mode = True - http_conf = (await http.async_get_last_config(hass)) or {} - - await async_from_config_dict( - {"recovery_mode": {}, "http": http_conf}, - hass, - ) + await async_from_config_dict({"recovery_mode": {}}, hass) if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 247da3e1b6d2..102985e6b149 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -12,7 +12,7 @@ from pathlib import Path import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, TypedDict, cast, override +from typing import Any, Final, cast, override from aiohttp import web from aiohttp.abc import AbstractStreamWriter @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, @@ -60,7 +60,29 @@ from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans -from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401 +from .config import async_load_config +from .const import ( # noqa: F401 + CONF_BASE_URL, + CONF_CORS_ORIGINS, + CONF_IP_BAN_ENABLED, + CONF_LOGIN_ATTEMPTS_THRESHOLD, + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + CONF_SSL_PEER_CERTIFICATE, + CONF_SSL_PROFILE, + CONF_TRUSTED_PROXIES, + CONF_USE_X_FORWARDED_FOR, + CONF_USE_X_FRAME_OPTIONS, + DEFAULT_CORS, + DOMAIN, + KEY_HASS_REFRESH_TOKEN_ID, + KEY_HASS_USER, + NO_LOGIN_ATTEMPT_THRESHOLD, + SSL_INTERMEDIATE, + SSL_MODERN, +) from .cors import setup_cors from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded @@ -70,38 +92,13 @@ from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite -CONF_SERVER_HOST: Final = "server_host" -CONF_SERVER_PORT: Final = "server_port" -CONF_BASE_URL: Final = "base_url" -CONF_SSL_CERTIFICATE: Final = "ssl_certificate" -CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate" -CONF_SSL_KEY: Final = "ssl_key" -CONF_CORS_ORIGINS: Final = "cors_allowed_origins" -CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for" -CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options" -CONF_TRUSTED_PROXIES: Final = "trusted_proxies" -CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" -CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" -CONF_SSL_PROFILE: Final = "ssl_profile" - -SSL_MODERN: Final = "modern" -SSL_INTERMEDIATE: Final = "intermediate" - _LOGGER: Final = logging.getLogger(__name__) DEFAULT_DEVELOPMENT: Final = "0" -# Cast to be able to load custom cards. -# My to be able to check url and version info. -DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"] -NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1 MAX_CLIENT_SIZE: Final = 1024**2 * 16 MAX_LINE_SIZE: Final = 24570 -STORAGE_KEY: Final = DOMAIN -STORAGE_VERSION: Final = 1 -SAVE_DELAY: Final = 180 - _HAS_IPV6 = hasattr(socket, "AF_INET6") _DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] @@ -154,30 +151,6 @@ _STATIC_CLASSES = { } -class ConfData(TypedDict, total=False): - """Typed dict for config data.""" - - server_host: list[str] - server_port: int - base_url: str - ssl_certificate: str - ssl_peer_certificate: str - ssl_key: str - cors_allowed_origins: list[str] - use_x_forwarded_for: bool - use_x_frame_options: bool - trusted_proxies: list[IPv4Network | IPv6Network] - login_attempts_threshold: int - ip_ban_enabled: bool - ssl_profile: str - - -async def async_get_last_config(hass: HomeAssistant) -> dict[str, Any] | None: - """Return the last known working config.""" - store = storage.Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - return await store.async_load() - - class ApiConfig: """Configuration settings for API server.""" @@ -201,10 +174,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # we import aiohttp_fast_zlib (await async_import_module(hass, "aiohttp_fast_zlib")).enable() - conf: ConfData | None = config.get(DOMAIN) + # Deferred import: websocket_api declares http as its manifest + # dependency and imports back into this package at module load + # (websocket_api/http.py -> homeassistant.components.http). A top-level + # import of .websocket_api here would re-enter the still-loading + # websocket_api package and fail when applying its decorators + # (e.g. @websocket_api.require_admin). + websocket_api_module = await async_import_module( + hass, "homeassistant.components.http.websocket_api" + ) - if conf is None: - conf = cast(ConfData, HTTP_SCHEMA({})) + conf = await async_load_config(hass, config) + + websocket_api_module.async_register_websocket_commands(hass) if CONF_SERVER_HOST in conf and is_hassio(hass): issue_id = "server_host_deprecated_hassio" @@ -271,9 +253,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Start the server.""" with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) - # We already checked it's not None. - assert conf is not None - await start_http_server_and_save_config(hass, dict(conf), server) + await server.start() async_when_setup_or_start(hass, "frontend", start_server) @@ -711,23 +691,3 @@ class HomeAssistantHTTP: await self.site.stop() if self.runner is not None: await self.runner.cleanup() - - -async def start_http_server_and_save_config( - hass: HomeAssistant, conf: dict, server: HomeAssistantHTTP -) -> None: - """Startup the http server and save the config.""" - await server.start() - - # If we are set up successful, we store the HTTP settings for recovery mode. - store: storage.Store[dict[str, Any]] = storage.Store( - hass, STORAGE_VERSION, STORAGE_KEY - ) - - if CONF_TRUSTED_PROXIES in conf: - conf[CONF_TRUSTED_PROXIES] = [ - str(cast(IPv4Network | IPv6Network, ip).network_address) - for ip in conf[CONF_TRUSTED_PROXIES] - ] - - store.async_delay_save(lambda: conf, SAVE_DELAY) diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py new file mode 100644 index 000000000000..066c3a371f2d --- /dev/null +++ b/homeassistant/components/http/config.py @@ -0,0 +1,321 @@ +"""User-managed HTTP configuration store.""" + +import asyncio +from ipaddress import IPv4Network, IPv6Network, ip_network +import logging +from typing import Any, Final, TypedDict, cast, override + +import voluptuous as vol + +from homeassistant.const import SERVER_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey + +from .const import ( + CONF_BASE_URL, + CONF_CORS_ORIGINS, + CONF_IP_BAN_ENABLED, + CONF_LOGIN_ATTEMPTS_THRESHOLD, + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + CONF_SSL_PEER_CERTIFICATE, + CONF_SSL_PROFILE, + CONF_TRUSTED_PROXIES, + CONF_USE_X_FORWARDED_FOR, + CONF_USE_X_FRAME_OPTIONS, + DEFAULT_CORS, + DOMAIN, + NO_LOGIN_ATTEMPT_THRESHOLD, + SSL_INTERMEDIATE, + SSL_MODERN, +) + +_LOGGER = logging.getLogger(__name__) + +STORAGE_KEY: Final = DOMAIN +STORAGE_VERSION: Final = 2 + +KEY_STABLE: Final = "stable" +KEY_PENDING: Final = "pending" +KEY_YAML_MIGRATION_DONE: Final = "yaml_migration_done" + +DATA_STORE: HassKey[HTTPConfigStore] = HassKey(STORAGE_KEY) + + +class ConfData(TypedDict, total=False): + """Typed dict for the validated HTTP config (matches ``HTTP_STORAGE_SCHEMA``).""" + + server_host: list[str] + server_port: int + ssl_certificate: str + ssl_peer_certificate: str + ssl_key: str + cors_allowed_origins: list[str] + use_x_forwarded_for: bool + trusted_proxies: list[IPv4Network | IPv6Network] + login_attempts_threshold: int + ip_ban_enabled: bool + ssl_profile: str + use_x_frame_options: bool + + +class _HTTPStoreData(TypedDict): + """Data structure for HTTP config storage.""" + + stable: ConfData + pending: ConfData | None + yaml_migration_done: bool + + +def _ip_network_str(value: Any) -> str: + """Validate the value is a valid IP network and return its string form.""" + return str(ip_network(value)) + + +HTTP_STORAGE_SCHEMA: Final = vol.Schema( + { + # YAML used to allow base_url (deprecated); strip it on the way in so + # the stored config never contains it. + vol.Remove(CONF_BASE_URL): object, + vol.Optional(CONF_SERVER_HOST): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), + vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, + vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_KEY): cv.isfile, + vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean, + vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All( + cv.ensure_list, [_ip_network_str] + ), + vol.Optional( + CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD + ): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), + vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( + [SSL_INTERMEDIATE, SSL_MODERN] + ), + vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, + } +) +_DEFAULT_CONFIG: Final[ConfData] = cast(ConfData, HTTP_STORAGE_SCHEMA({})) + + +async def async_load_config(hass: HomeAssistant, config: ConfigType) -> ConfData: + """Load the HTTP config to apply on this startup. + + YAML config is only migrated once. Subsequent boots will ignore YAML and + use the store exclusively. + + Resolution order: + - Recovery mode: always use ``stable`` so HA stays reachable after a bad + config; YAML is ignored entirely (any pending YAML migration is + deferred to the next normal boot). + - Normal mode: prefer ``pending`` if set, otherwise ``stable``. + """ + store = await async_get_and_load_store(hass) + if hass.config.recovery_mode: + _LOGGER.info("Recovery mode active; using stable HTTP config") + return store.stable + + yaml_conf: ConfData | None = config.get(DOMAIN) + if store.yaml_migration_done: + if yaml_conf is not None: + # YAML is still present after migration completed; surface a repair + # issue so the user knows their YAML is being ignored. + ir.async_create_issue( + hass, + DOMAIN, + "yaml_still_present_after_migration", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="yaml_still_present_after_migration", + ) + else: + # Clear any leftover deprecation issues if YAML was removed after migration. + ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml_import_error") + ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml") + ir.async_delete_issue(hass, DOMAIN, "yaml_still_present_after_migration") + else: + # Migrate YAML to storage and use it directly for this start. The + # migration function also marks the migration as done so future + # starts will ignore any remaining YAML. + conf_in_yaml = yaml_conf is not None + if yaml_conf is None: + yaml_conf = cast(ConfData, HTTP_STORAGE_SCHEMA({})) + + try: + await store.async_migrate_yaml(yaml_conf) + except Exception: + _LOGGER.exception("Failed to migrate HTTP YAML configuration to storage") + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_error", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="deprecated_yaml_import_error", + ) + else: + if conf_in_yaml: + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2027.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + if store.pending is not None: + _LOGGER.info("Using pending HTTP config") + return store.pending + + _LOGGER.info("Using stable HTTP config") + return store.stable + + +async def async_get_and_load_store(hass: HomeAssistant) -> HTTPConfigStore: + """Return the singleton HTTP config store and load it.""" + if (store := hass.data.get(DATA_STORE)) is None: + store = HTTPConfigStore(hass) + hass.data[DATA_STORE] = store + await store.async_load() + return store + + +class HTTPConfigStore: + """Persist HTTP config as a stable/pending pair. + + ``stable`` holds the last config the user confirmed as working; + ``pending`` holds an unconfirmed config the user wants to try on + the next start. Normal startup prefers ``pending`` so the new + config gets exercised; recovery mode falls back to ``stable`` so + Home Assistant can still come up after a bad config. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the store.""" + self._hass = hass + self._store = _HTTPStore( + hass, + STORAGE_VERSION, + STORAGE_KEY, + private=True, + atomic_writes=True, + ) + self._stable: ConfData = _DEFAULT_CONFIG + self._pending: ConfData | None = None + self._yaml_migration_done = False + self._loaded = False + self._load_lock = asyncio.Lock() + + @property + def stable(self) -> ConfData: + """Return the last confirmed-working config.""" + return self._stable + + @property + def pending(self) -> ConfData | None: + """Return the unconfirmed config awaiting promotion, if any.""" + return self._pending + + @property + def yaml_migration_done(self) -> bool: + """Return whether the YAML migration has been completed.""" + return self._yaml_migration_done + + async def async_load(self) -> None: + """Load the stable and pending configs from disk.""" + if self._loaded: + return + async with self._load_lock: + if self._loaded: + # Another coroutine may have loaded the config while we were waiting + # for the lock; check again to avoid unnecessary disk I/O. + return # type: ignore[unreachable] + raw = await self._store.async_load() + if raw is not None: + self._stable = raw[KEY_STABLE] + self._pending = raw[KEY_PENDING] + self._yaml_migration_done = raw[KEY_YAML_MIGRATION_DONE] + self._loaded = True + + async def async_set_pending(self, config: ConfData | None) -> None: + """Set (or clear) the pending config.""" + await self.async_load() + if config == self.stable: + # No need to save a pending config that is the same as stable. + config = None + self._pending = config + await self._async_persist() + + async def async_promote_pending(self) -> None: + """Promote the pending config to stable. + + Raises ``HomeAssistantError`` if there is nothing to promote. + """ + await self.async_load() + if self._pending is None: + raise HomeAssistantError("No pending HTTP config to promote") + self._stable = self._pending + self._pending = None + await self._async_persist() + + async def async_migrate_yaml(self, config: ConfData) -> None: + """Migrate YAML config to storage as pending if not the same as the config used for recovery.""" + await self.async_load() + validated_config = cast(ConfData, HTTP_STORAGE_SCHEMA(config)) + self._pending = None if validated_config == self._stable else validated_config + self._yaml_migration_done = True + await self._async_persist() + + async def _async_persist(self) -> None: + """Write the current state to disk (or remove the file if empty).""" + await self._store.async_save( + { + KEY_STABLE: self._stable, + KEY_PENDING: self._pending, + KEY_YAML_MIGRATION_DONE: self._yaml_migration_done, + } + ) + + +class _HTTPStore(Store[_HTTPStoreData]): + """Http store.""" + + @override + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + if old_major_version == 1: + # Run the v1 payload through the storage schema so the v2 ``stable`` + # slot is well-formed (all keys present, values normalised) and the + # load step can rely on direct key access. + try: + stable = HTTP_STORAGE_SCHEMA(old_data) + except vol.Invalid: + _LOGGER.warning( + "Discarding invalid v1 HTTP config during migration; " + "falling back to defaults" + ) + stable = _DEFAULT_CONFIG + return { + KEY_STABLE: stable, + KEY_PENDING: None, + KEY_YAML_MIGRATION_DONE: False, + } + return old_data diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index c89751a62aff..ae30fc9aecbb 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -11,6 +11,30 @@ DOMAIN: Final = "http" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" +CONF_SERVER_HOST: Final = "server_host" +CONF_SERVER_PORT: Final = "server_port" +CONF_BASE_URL: Final = "base_url" +CONF_SSL_CERTIFICATE: Final = "ssl_certificate" +CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate" +CONF_SSL_KEY: Final = "ssl_key" +CONF_CORS_ORIGINS: Final = "cors_allowed_origins" +CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for" +CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options" +CONF_TRUSTED_PROXIES: Final = "trusted_proxies" +CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" +CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" +CONF_SSL_PROFILE: Final = "ssl_profile" + +SSL_MODERN: Final = "modern" +SSL_INTERMEDIATE: Final = "intermediate" + +# Cast to be able to load custom cards. +# My to be able to check url and version info. +DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"] +NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1 + +ATTR_CONFIG = "config" + def is_supervisor_unix_socket_request(request: Request) -> bool: """Check if request arrived over the Supervisor Unix socket.""" diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json index b74cfd457b22..2ef22ae18052 100644 --- a/homeassistant/components/http/strings.json +++ b/homeassistant/components/http/strings.json @@ -1,5 +1,13 @@ { "issues": { + "deprecated_yaml": { + "description": "Your existing HTTP configuration from `configuration.yaml` has been imported. The `http` integration is now configured from the UI under **Settings** > **System** > **Network**.\n\nPlease remove the `http:` block from your `configuration.yaml` and restart Home Assistant.", + "title": "The HTTP YAML configuration is deprecated" + }, + "deprecated_yaml_import_error": { + "description": "Migrating the `http` configuration from `configuration.yaml` to the integration's storage failed. Please check the logs for details and configure the `http` integration from the UI under **Settings** > **System** > **Network**.", + "title": "Failed to import HTTP YAML configuration" + }, "server_host_deprecated_hassio": { "description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.", "title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication" @@ -7,6 +15,10 @@ "ssl_configured_without_configured_urls": { "description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.", "title": "SSL is configured without an external URL or internal URL" + }, + "yaml_still_present_after_migration": { + "description": "The HTTP configuration in `configuration.yaml` has already been migrated and is now being ignored. Please remove the `http:` block from your `configuration.yaml`. Manage the HTTP configuration from the UI under **Settings** > **System** > **Network**.", + "title": "HTTP YAML configuration is ignored after migration" } } } diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py new file mode 100644 index 000000000000..f73101fa05e4 --- /dev/null +++ b/homeassistant/components/http/websocket_api.py @@ -0,0 +1,85 @@ +"""WebSocket API for the HTTP integration user config.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from .config import HTTP_STORAGE_SCHEMA, async_get_and_load_store +from .const import ATTR_CONFIG + + +@callback +def async_register_websocket_commands(hass: HomeAssistant) -> None: + """Register the HTTP config websocket commands.""" + websocket_api.async_register_command(hass, websocket_get_config) + websocket_api.async_register_command(hass, websocket_set_config) + websocket_api.async_register_command(hass, websocket_promote_config) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "http/config"}) +@websocket_api.async_response +async def websocket_get_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return the active HTTP configuration (the confirmed-working ``stable`` slot).""" + store = await async_get_and_load_store(hass) + connection.send_result(msg["id"], store.stable) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "http/config/configure", + vol.Required(ATTR_CONFIG): vol.Any(None, HTTP_STORAGE_SCHEMA), + } +) +@websocket_api.async_response +async def websocket_set_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Store a new pending HTTP configuration. + + The new config is not applied until Home Assistant is restarted + and the user promotes it via ``http/config/promote``. Until then + the existing ``stable`` config remains the recovery fallback. + """ + store = await async_get_and_load_store(hass) + await store.async_set_pending(msg[ATTR_CONFIG]) + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "http/config/promote"}) +@websocket_api.async_response +async def websocket_promote_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Promote the pending HTTP config to stable. + + Called by the user after they have verified Home Assistant is + working correctly with the pending config. The stable config is + the one used by recovery mode, so promotion must be explicit. + """ + store = await async_get_and_load_store(hass) + try: + await store.async_promote_pending() + except HomeAssistantError as err: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_ALLOWED, + str(err), + ) + return + + connection.send_result(msg["id"]) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index ab0acd3d1fac..3b852d7883fc 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -351,7 +351,7 @@ async def test_expose_flag_automatically_set( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - with patch("homeassistant.components.http.start_http_server_and_save_config"): + with patch("homeassistant.components.http.HomeAssistantHTTP.start"): await hass.async_start() # After setting up conversation, the expose flag should now be set on all entities diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5b231c8c3687..a8620acff534 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -2,12 +2,11 @@ import asyncio from collections.abc import Callable -from datetime import timedelta from http import HTTPStatus -from ipaddress import ip_network import logging import os from pathlib import Path +from typing import Any from unittest.mock import ANY, Mock, patch import pytest @@ -16,17 +15,17 @@ from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import cloud, http from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.http import DOMAIN +from homeassistant.components.http.config import _DEFAULT_CONFIG, HTTP_STORAGE_SCHEMA from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_call_logger_set_level, async_fire_time_changed -from tests.typing import ClientSessionGenerator +from tests.common import async_call_logger_set_level +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) @@ -315,26 +314,46 @@ async def test_peer_cert(hass: HomeAssistant, tmp_path: Path) -> None: assert len(mock_load_verify_locations.mock_calls) == 1 +def _stable_http_storage( + stable: dict, *, pending: dict | None = None, yaml_migration_done: bool = True +) -> dict: + """Build a hass_storage entry seeded with a confirmed-working stable config. + + ``stable`` (and ``pending`` if given) are normalised through the storage + schema, matching what real users have on disk after migration / writes — + the load path does direct key access and assumes the payload is complete. + """ + normalised_stable = dict(HTTP_STORAGE_SCHEMA(stable)) + normalised_pending = dict(HTTP_STORAGE_SCHEMA(pending)) if pending else None + return { + "version": 2, + "key": DOMAIN, + "data": { + "stable": normalised_stable, + "pending": normalised_pending, + "yaml_migration_done": yaml_migration_done, + }, + } + + async def test_emergency_ssl_certificate_when_invalid( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test http starts with emergency self-signed cert on invalid cert.""" cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - - hass.config.recovery_mode = True - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, - }, - ) - is True + # In recovery mode YAML is ignored, so seed the broken SSL paths into the + # store's stable slot — that's the only config recovery mode will look at. + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} ) + hass.config.recovery_mode = True + assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() await hass.async_block_till_done() @@ -365,7 +384,10 @@ async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode( async def test_emergency_ssl_certificate_when_invalid_get_url_fails( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test http falls back to no ssl when emergency cert creation fails. @@ -374,21 +396,15 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} + ) hass.config.recovery_mode = True with patch( "homeassistant.components.http.get_url", side_effect=NoURLAvailableError ) as mock_get_url: - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, - }, - ) - is True - ) + assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() await hass.async_block_till_done() @@ -403,28 +419,25 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( async def test_invalid_ssl_and_cannot_create_emergency_cert( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test http falls back to no ssl on emergency cert creation failure.""" cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} + ) hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError ) as mock_builder: - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, - }, - ) - is True - ) + assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() await hass.async_block_till_done() assert "Could not create an emergency self signed ssl certificate" in caplog.text @@ -434,7 +447,10 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test no-ssl fallback with peer cert when emergency cert fails. @@ -447,25 +463,19 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) + hass_storage[DOMAIN] = _stable_http_storage( + { + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + "ssl_peer_certificate": str(cert_path), + } + ) hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError ) as mock_builder: - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": { - "ssl_certificate": cert_path, - "ssl_key": key_path, - "ssl_peer_certificate": cert_path, - }, - }, - ) - is False - ) + assert await async_setup_component(hass, DOMAIN, {}) is False await hass.async_start() await hass.async_block_till_done() assert "Could not create an emergency self signed ssl certificate" in caplog.text @@ -481,31 +491,6 @@ async def test_cors_defaults(hass: HomeAssistant) -> None: assert mock_setup.mock_calls[0][1][1] == ["https://cast.home-assistant.io"] -async def test_storing_config( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - unused_tcp_port_factory: Callable[[], int], -) -> None: - """Test that we store last working config.""" - config = { - http.CONF_SERVER_PORT: unused_tcp_port_factory(), - "use_x_forwarded_for": True, - "trusted_proxies": ["192.168.1.100"], - } - - assert await async_setup_component(hass, http.DOMAIN, {http.DOMAIN: config}) - - await hass.async_start() - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - - restored = await http.async_get_last_config(hass) - restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0]) - - assert restored == http.HTTP_SCHEMA(config) - - async def test_logging( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -691,16 +676,24 @@ async def test_ssl_issue_urls_configured( "expected_issues", ), [ - (False, {}, ["0.0.0.0", "::"], set()), - (False, {"server_host": "0.0.0.0"}, ["0.0.0.0"], set()), - (True, {}, ["0.0.0.0", "::"], set()), + (False, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), + ( + False, + {"server_host": "0.0.0.0"}, + ["0.0.0.0"], + {("http", "deprecated_yaml")}, + ), + (True, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), ( True, {"server_host": "0.0.0.0"}, [ "0.0.0.0", ], - {("http", "server_host_deprecated_hassio")}, + { + ("http", "server_host_deprecated_hassio"), + ("http", "deprecated_yaml"), + }, ), ], ) @@ -811,3 +804,499 @@ async def test_unix_socket_rejected_relative_path( assert hass.http.supervisor_site is None assert "path must be absolute" in caplog.text + + +async def test_yaml_migration_to_storage( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test YAML config is migrated to the HTTP config store with a deprecation issue. + + With no prior store, the migration stages YAML in ``pending`` (stable stays + on the schema defaults). The pending slot is what HA boots from until the + user confirms / promotes it via the UI. + """ + yaml_conf = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + } + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue is not None + assert issue.severity is ir.IssueSeverity.WARNING + + assert ( + issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") is None + ) + + stored = hass_storage[DOMAIN]["data"] + assert stored["yaml_migration_done"] is True + assert stored["stable"]["server_port"] == 8123 # untouched defaults + pending = stored["pending"] + assert pending is not None + assert pending["server_port"] == 9123 + assert pending["cors_allowed_origins"] == ["https://example.com"] + assert pending["trusted_proxies"] == ["127.0.0.0/8"] + assert pending["ip_ban_enabled"] is False + + +async def test_yaml_migration_matches_stable_no_pending( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """If the YAML matches the existing stable config, no pending should be created.""" + existing_stable = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + "login_attempts_threshold": -1, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": { + "stable": existing_stable, + "pending": None, + "yaml_migration_done": False, + }, + } + + yaml_conf = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + } + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + stored = hass_storage[DOMAIN]["data"] + assert stored["pending"] is None + assert stored["stable"] == existing_stable + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue is not None + + +async def test_yaml_migration_differs_from_stable_creates_pending( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """If the YAML differs from the existing stable config, it must be stored as pending.""" + existing_stable = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "login_attempts_threshold": -1, + "ip_ban_enabled": True, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": { + "stable": existing_stable, + "pending": None, + "yaml_migration_done": False, + }, + } + + yaml_conf = {"server_port": 8765, "ip_ban_enabled": False} + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + stored = hass_storage[DOMAIN]["data"] + assert stored["stable"] == existing_stable + assert stored["pending"] == { + "server_port": 8765, + "cors_allowed_origins": ["https://cast.home-assistant.io"], + "login_attempts_threshold": -1, + "ip_ban_enabled": False, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue is not None + + +async def test_yaml_migration_failure_creates_error_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that an error during YAML migration creates an error issue.""" + yaml_conf = {"server_port": 9123} + + with ( + patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), + patch( + "homeassistant.components.http.config.HTTPConfigStore.async_migrate_yaml", + side_effect=RuntimeError("boom"), + ), + ): + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") + assert issue is not None + assert issue.severity is ir.IssueSeverity.ERROR + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + + +async def test_yaml_still_present_after_migration_creates_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """When YAML lingers after migration, a repair issue is surfaced and YAML is ignored.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, yaml_migration_done=True + ) + + yaml_conf = {"server_port": 1234} + mock_server = Mock() + with patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server: + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + # YAML must be ignored once migration is done; stable wins. + args, _ = mock_create_server.call_args + assert args[2] == 9876 + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") + assert issue is not None + assert issue.severity is ir.IssueSeverity.WARNING + + +async def test_yaml_still_present_issue_cleared_when_yaml_removed( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """A previously created leftover-YAML issue is cleared once YAML is removed.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, yaml_migration_done=True + ) + ir.async_create_issue( + hass, + DOMAIN, + "yaml_still_present_after_migration", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="yaml_still_present_after_migration", + ) + + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") + is None + ) + + +async def test_setup_uses_stable_config_when_no_yaml( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test HTTP config is loaded from the stable slot when no YAML or pending is set.""" + hass_storage[DOMAIN] = _stable_http_storage( + { + "server_port": 9876, + "cors_allowed_origins": ["https://stored.example.com"], + } + ) + + mock_server = Mock() + with patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + args, _ = mock_create_server.call_args + assert args[2] == 9876 + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + assert ( + issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") is None + ) + + +async def test_setup_prefers_pending_over_stable_in_normal_mode( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Pending overrides stable on a normal boot so the new config gets tested.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + + mock_server = Mock() + with patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + args, _ = mock_create_server.call_args + assert args[2] == 9999 + + +async def test_recovery_mode_falls_back_to_stable( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """In recovery mode the pending config is ignored to keep HA reachable.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + hass.config.recovery_mode = True + + mock_server = Mock() + with patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + args, _ = mock_create_server.call_args + assert args[2] == 9876 + + +async def test_recovery_mode_with_no_storage( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Recovery mode with no prior storage starts HTTP on the schema defaults. + + This covers the first-ever-boot-into-recovery-mode case: bootstrap fell + through to recovery before HTTP ever had a chance to migrate YAML, so the + store is empty and we must come up cleanly on defaults. + """ + assert "http" not in hass_storage + hass.config.recovery_mode = True + + mock_server = Mock() + with patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + args, _ = mock_create_server.call_args + assert args[2] == 8123 + # Recovery mode must not trigger YAML migration side effects. + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + + +async def test_recovery_mode_ignores_yaml( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """YAML config must not be applied or migrated while in recovery mode. + + The whole point of recovery mode is to ignore the user's (possibly bad) + config and fall back to the last known good ``stable`` slot. Migrating + YAML here would defeat that and could re-introduce the broken config. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 5555}, yaml_migration_done=False + ) + hass.config.recovery_mode = True + + mock_server = Mock() + with patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server: + assert await async_setup_component( + hass, DOMAIN, {"http": {"server_port": 1234}} + ) + await hass.async_start() + await hass.async_block_till_done() + + args, _ = mock_create_server.call_args + # YAML's port must NOT win: stable is the only source of truth in recovery. + assert args[2] == 5555 + # The migration must not run in recovery mode, so its flag stays untouched + # and no deprecation issue is created on this boot. + assert hass_storage[DOMAIN]["data"]["yaml_migration_done"] is False + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + + +async def test_setup_migrates_v1_storage_to_v2( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """An existing v1 store is migrated into the stable slot.""" + hass_storage[DOMAIN] = { + "version": 1, + "key": "http", + "data": {"server_port": 9876}, + } + + mock_server = Mock() + with patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + # The migrated v1 store config is only used in recovery mode. Since this + # test isn't running in recovery mode, the YAML migration runs on first + # boot after store migration. With no YAML http config, the default config is migrated to the pending slot and used. Therefore we assert below the default port (8123) + args, _ = mock_create_server.call_args + assert args[2] == 8123 + assert hass_storage[DOMAIN]["version"] == 2 + data = hass_storage[DOMAIN]["data"] + # The v1→v2 migration normalises the payload through the storage schema, + # so the v2 stable slot is well-formed (all keys present) on disk. + assert data["stable"]["server_port"] == 9876 + assert data["stable"]["ip_ban_enabled"] is True + assert data["pending"] == _DEFAULT_CONFIG + assert data["yaml_migration_done"] is True + + +async def test_websocket_http_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test the http/config, configure and promote websocket commands.""" + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + # On a fresh setup the stable slot is seeded with the schema defaults. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"]["server_port"] == 8123 + assert response["result"]["ip_ban_enabled"] is True + + new_config = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + "login_attempts_threshold": 5, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": new_config} + ) + response = await ws_client.receive_json() + assert response["success"] + # Configure is an ack; verify pending state via storage. + pending = hass_storage["http"]["data"]["pending"] + assert pending["server_port"] == 9123 + assert pending["trusted_proxies"] == ["127.0.0.0/8"] + # Stable is unchanged until the user promotes. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"]["server_port"] == 8123 + + # Promote: pending becomes stable, pending is cleared. + await ws_client.send_json_auto_id({"type": "http/config/promote"}) + response = await ws_client.receive_json() + assert response["success"] + assert hass_storage["http"]["data"]["pending"] is None + assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123 + + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"]["server_port"] == 9123 + + # Promoting again with no pending is rejected. + await ws_client.send_json_auto_id({"type": "http/config/promote"}) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "not_allowed" + + # Clearing pending leaves stable untouched. + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": {"server_port": 7000}} + ) + response = await ws_client.receive_json() + assert response["success"] + assert hass_storage["http"]["data"]["pending"]["server_port"] == 7000 + + await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None}) + response = await ws_client.receive_json() + assert response["success"] + assert hass_storage["http"]["data"]["pending"] is None + assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123 + + +@pytest.mark.parametrize( + "config", + [ + {"server_port": "not-a-port"}, + { + "use_x_forwarded_for": True, + "trusted_proxies": ["not-an-ip-network"], + }, + ], +) +async def test_websocket_http_config_invalid( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + config: dict, +) -> None: + """Test that an invalid HTTP config is rejected.""" + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": config} + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_format" diff --git a/tests/conftest.py b/tests/conftest.py index eaea4d3c8d17..9b1c732efb9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2263,5 +2263,5 @@ def disable_http_server() -> Generator[None]: This prevents the HTTP server from starting in tests that setup integrations which depend on the HTTP component. """ - with patch("homeassistant.components.http.start_http_server_and_save_config"): + with patch("homeassistant.components.http.HomeAssistantHTTP.start"): yield diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e509788053f6..bc0ced1b5c3b 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -76,7 +76,7 @@ def disable_block_async_io(disable_block_async_io): def mock_http_start_stop() -> Generator[None]: """Mock HTTP start and stop.""" with ( - patch("homeassistant.components.http.start_http_server_and_save_config"), + patch("homeassistant.components.http.HomeAssistantHTTP.start"), patch("homeassistant.components.http.HomeAssistantHTTP.stop"), ): yield From 1e8e23d70a75023bca1bfd635bd8dae3d91cfd76 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 17 Jun 2026 13:35:44 +0200 Subject: [PATCH 002/707] Restart HA on saving new http conf and also return it on http/config (#173103) --- .../components/http/websocket_api.py | 30 ++++++++++---- tests/components/http/test_init.py | 41 +++++++++++++++---- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py index f73101fa05e4..66537e61e303 100644 --- a/homeassistant/components/http/websocket_api.py +++ b/homeassistant/components/http/websocket_api.py @@ -5,6 +5,10 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api +from homeassistant.components.homeassistant import ( + DOMAIN as HASS_DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -28,9 +32,16 @@ async def websocket_get_config( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Return the active HTTP configuration (the confirmed-working ``stable`` slot).""" + """Return the HTTP configuration. + + ``stable`` is the confirmed-working config + ``pending`` is an unconfirmed config awaiting promotion, or ``None``. + """ store = await async_get_and_load_store(hass) - connection.send_result(msg["id"], store.stable) + connection.send_result( + msg["id"], + {"stable": store.stable, "pending": store.pending}, + ) @websocket_api.require_admin @@ -46,15 +57,20 @@ async def websocket_set_config( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Store a new pending HTTP configuration. + """Store a new pending HTTP configuration and restart to apply it. - The new config is not applied until Home Assistant is restarted - and the user promotes it via ``http/config/promote``. Until then - the existing ``stable`` config remains the recovery fallback. + Restart whenever the pending slot changes, so the runtime config is + refreshed. The result reports whether a restart was triggered via + ``{"restart": bool}``. """ store = await async_get_and_load_store(hass) + previous_pending = store.pending await store.async_set_pending(msg[ATTR_CONFIG]) - connection.send_result(msg["id"]) + restart = store.pending != previous_pending + connection.send_result(msg["id"], {"restart": restart}) + + if restart: + await hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART) @websocket_api.require_admin diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index a8620acff534..6f6fad9545c9 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -24,7 +24,7 @@ from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_call_logger_set_level +from tests.common import async_call_logger_set_level, async_mock_service from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -1205,12 +1205,15 @@ async def test_websocket_http_config( ws_client = await hass_ws_client(hass) - # On a fresh setup the stable slot is seeded with the schema defaults. + # Staging a new config triggers a restart so the pending config is applied. + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + # On a fresh setup the stable slot is seeded with the schema defaults and + # there is no pending config. await ws_client.send_json_auto_id({"type": "http/config"}) response = await ws_client.receive_json() assert response["success"] - assert response["result"]["server_port"] == 8123 - assert response["result"]["ip_ban_enabled"] is True + assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": None} new_config = { "server_port": 9123, @@ -1227,15 +1230,19 @@ async def test_websocket_http_config( ) response = await ws_client.receive_json() assert response["success"] - # Configure is an ack; verify pending state via storage. + assert response["result"] == {"restart": True} pending = hass_storage["http"]["data"]["pending"] assert pending["server_port"] == 9123 assert pending["trusted_proxies"] == ["127.0.0.0/8"] - # Stable is unchanged until the user promotes. + await hass.async_block_till_done() + assert len(restart_calls) == 1 + + # Stable is unchanged until the user promotes, but the pending config is + # now returned alongside it. await ws_client.send_json_auto_id({"type": "http/config"}) response = await ws_client.receive_json() assert response["success"] - assert response["result"]["server_port"] == 8123 + assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": new_config} # Promote: pending becomes stable, pending is cleared. await ws_client.send_json_auto_id({"type": "http/config/promote"}) @@ -1247,7 +1254,7 @@ async def test_websocket_http_config( await ws_client.send_json_auto_id({"type": "http/config"}) response = await ws_client.receive_json() assert response["success"] - assert response["result"]["server_port"] == 9123 + assert response["result"] == {"stable": new_config, "pending": None} # Promoting again with no pending is rejected. await ws_client.send_json_auto_id({"type": "http/config/promote"}) @@ -1255,19 +1262,35 @@ async def test_websocket_http_config( assert not response["success"] assert response["error"]["code"] == "not_allowed" - # Clearing pending leaves stable untouched. + # Staging a different config again changes the pending slot -> restart. await ws_client.send_json_auto_id( {"type": "http/config/configure", "config": {"server_port": 7000}} ) response = await ws_client.receive_json() assert response["success"] + assert response["result"] == {"restart": True} assert hass_storage["http"]["data"]["pending"]["server_port"] == 7000 + await hass.async_block_till_done() + assert len(restart_calls) == 2 + # Clearing a previously staged config also changes the active config back + # to stable, so it must trigger a restart too. await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None}) response = await ws_client.receive_json() assert response["success"] + assert response["result"] == {"restart": True} assert hass_storage["http"]["data"]["pending"] is None assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123 + await hass.async_block_till_done() + assert len(restart_calls) == 3 + + # Clearing again when there is no pending config is a no-op -> no restart. + await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"restart": False} + await hass.async_block_till_done() + assert len(restart_calls) == 3 @pytest.mark.parametrize( From 72a2a88fea4e2cd8b82b768625b545845348ef4c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 19 Jun 2026 11:51:13 +0200 Subject: [PATCH 003/707] Add env var SETUP_PORT to change the default port (#174263) --- homeassistant/components/http/__init__.py | 5 +-- homeassistant/components/http/config.py | 26 ++++++++++++- homeassistant/components/http/const.py | 2 + tests/components/http/test_init.py | 47 ++++++++++++++++++++++- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 102985e6b149..85a4c3ead687 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -33,7 +33,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME, - SERVER_PORT, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -60,7 +59,7 @@ from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans -from .config import async_load_config +from .config import async_load_config, default_server_port from .const import ( # noqa: F401 CONF_BASE_URL, CONF_CORS_ORIGINS, @@ -109,7 +108,7 @@ HTTP_SCHEMA: Final = vol.All( vol.Optional(CONF_SERVER_HOST): vol.All( cv.ensure_list, vol.Length(min=1), [cv.string] ), - vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, + vol.Optional(CONF_SERVER_PORT, default=default_server_port): cv.port, vol.Optional(CONF_BASE_URL): cv.string, vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py index 066c3a371f2d..20952d51881c 100644 --- a/homeassistant/components/http/config.py +++ b/homeassistant/components/http/config.py @@ -3,6 +3,7 @@ import asyncio from ipaddress import IPv4Network, IPv6Network, ip_network import logging +import os from typing import Any, Final, TypedDict, cast, override import voluptuous as vol @@ -31,6 +32,7 @@ from .const import ( CONF_USE_X_FRAME_OPTIONS, DEFAULT_CORS, DOMAIN, + ENV_SETUP_PORT, NO_LOGIN_ATTEMPT_THRESHOLD, SSL_INTERMEDIATE, SSL_MODERN, @@ -38,6 +40,28 @@ from .const import ( _LOGGER = logging.getLogger(__name__) + +def default_server_port() -> int: + """Return the default HTTP server port. + + The built-in default port can be overridden via the + ``SETUP_PORT`` environment variable. An invalid value is ignored in favor + of the built-in default. + """ + if (env_value := os.environ.get(ENV_SETUP_PORT)) is None: + return SERVER_PORT + try: + return cast(int, cv.port(env_value)) + except vol.Invalid: + _LOGGER.warning( + "Invalid port %r in %s environment variable; falling back to %s", + env_value, + ENV_SETUP_PORT, + SERVER_PORT, + ) + return SERVER_PORT + + STORAGE_KEY: Final = DOMAIN STORAGE_VERSION: Final = 2 @@ -86,7 +110,7 @@ HTTP_STORAGE_SCHEMA: Final = vol.Schema( vol.Optional(CONF_SERVER_HOST): vol.All( cv.ensure_list, vol.Length(min=1), [cv.string] ), - vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, + vol.Optional(CONF_SERVER_PORT, default=default_server_port): cv.port, vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_KEY): cv.isfile, diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index ae30fc9aecbb..7426737904ab 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -28,6 +28,8 @@ CONF_SSL_PROFILE: Final = "ssl_profile" SSL_MODERN: Final = "modern" SSL_INTERMEDIATE: Final = "intermediate" +ENV_SETUP_PORT: Final = "SETUP_PORT" + # Cast to be able to load custom cards. # My to be able to check url and version info. DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"] diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 6f6fad9545c9..6ca3a5056074 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -15,7 +15,12 @@ from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import cloud, http from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.http import DOMAIN -from homeassistant.components.http.config import _DEFAULT_CONFIG, HTTP_STORAGE_SCHEMA +from homeassistant.components.http.config import ( + _DEFAULT_CONFIG, + HTTP_STORAGE_SCHEMA, + default_server_port, +) +from homeassistant.components.http.const import ENV_SETUP_PORT from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -1191,6 +1196,46 @@ async def test_setup_migrates_v1_storage_to_v2( assert data["yaml_migration_done"] is True +@pytest.mark.parametrize( + ("env", "expected_port"), + [ + pytest.param({}, 8123, id="unset"), + pytest.param({ENV_SETUP_PORT: "80"}, 80, id="valid"), + pytest.param({ENV_SETUP_PORT: "0"}, 8123, id="out-of-range"), + pytest.param({ENV_SETUP_PORT: "notaport"}, 8123, id="not-a-number"), + pytest.param({ENV_SETUP_PORT: ""}, 8123, id="empty"), + ], +) +def test_default_server_port( + env: dict[str, str], + expected_port: int, +) -> None: + """Test SETUP_PORT overrides the default port and invalid values fall back.""" + with patch.dict(os.environ, env, clear=True): + assert default_server_port() == expected_port + + +async def test_setup_port_env_var_used_as_default( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test SETUP_PORT is used as the default server port without YAML config.""" + mock_server = Mock() + with ( + patch.dict(os.environ, {ENV_SETUP_PORT: "80"}), + patch( + "asyncio.BaseEventLoop.create_server", return_value=mock_server + ) as mock_create_server, + ): + assert await async_setup_component(hass, "http", {}) + await hass.async_start() + await hass.async_block_till_done() + + args, _ = mock_create_server.call_args + assert args[2] == 80 + assert hass_storage["http"]["data"]["pending"]["server_port"] == 80 + + async def test_websocket_http_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From c1dd0065a338478815fed384a9cb7bef46e03187 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 3 Jul 2026 10:51:06 +0200 Subject: [PATCH 004/707] Remove unnecessary HTTP configuration from hassio component tests (#175393) --- tests/components/hassio/test_binary_sensor.py | 6 +- tests/components/hassio/test_diagnostics.py | 2 +- tests/components/hassio/test_sensor.py | 4 +- tests/components/hassio/test_switch.py | 2 +- tests/components/hassio/test_update.py | 68 +++++++++---------- tests/components/hassio/test_websocket_api.py | 18 ++--- 6 files changed, 50 insertions(+), 50 deletions(-) diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index c4a0d6c0e859..b848532943c5 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -110,7 +110,7 @@ async def test_binary_sensor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -141,7 +141,7 @@ async def test_mount_binary_sensor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -237,7 +237,7 @@ async def test_mount_refresh_after_issue( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 0c1b1d2399dc..440ceaa3263a 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -98,7 +98,7 @@ async def test_diagnostics( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index b53b3a40995c..a6d873103347 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -120,7 +120,7 @@ async def test_sensor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -171,7 +171,7 @@ async def test_stats_addon_sensor( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_switch.py b/tests/components/hassio/test_switch.py index a194e46f91c7..50109d00be7c 100644 --- a/tests/components/hassio/test_switch.py +++ b/tests/components/hassio/test_switch.py @@ -34,7 +34,7 @@ async def setup_integration( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index e586fcc311c1..c8f06e6ef865 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -135,7 +135,7 @@ async def test_update_entities( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -157,7 +157,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -186,7 +186,7 @@ async def test_update_addon_progress( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -289,7 +289,7 @@ async def test_addon_update_progress_startup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -386,7 +386,7 @@ async def test_update_addon_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -480,7 +480,7 @@ async def test_update_addon_with_backup_removes_old_backups( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -532,7 +532,7 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -628,7 +628,7 @@ async def test_update_os_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -666,7 +666,7 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -698,7 +698,7 @@ async def test_update_core_progress( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -848,7 +848,7 @@ async def test_core_update_progress_startup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -944,7 +944,7 @@ async def test_update_core_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -983,7 +983,7 @@ async def test_update_core_sets_progress_immediately( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1027,7 +1027,7 @@ async def test_update_core_resets_progress_on_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1066,7 +1066,7 @@ async def test_update_addon_sets_progress_immediately( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1113,7 +1113,7 @@ async def test_update_addon_resets_progress_on_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1240,7 +1240,7 @@ async def test_update_addon_stays_in_progress_until_refresh( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1315,7 +1315,7 @@ async def test_update_addon_completes_on_any_version_change( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1346,7 +1346,7 @@ async def test_update_supervisor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1380,7 +1380,7 @@ async def test_update_supervisor_progress( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1482,7 +1482,7 @@ async def test_update_supervisor_stays_in_progress_until_restart( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1548,7 +1548,7 @@ async def test_update_supervisor_completes_on_any_version_change( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1602,7 +1602,7 @@ async def test_update_addon_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1638,7 +1638,7 @@ async def test_update_addon_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -1675,7 +1675,7 @@ async def test_update_os_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1703,7 +1703,7 @@ async def test_update_os_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -1739,7 +1739,7 @@ async def test_update_supervisor_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1766,7 +1766,7 @@ async def test_update_core_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1794,7 +1794,7 @@ async def test_update_core_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -1831,7 +1831,7 @@ async def test_release_notes_between_versions( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1867,7 +1867,7 @@ async def test_release_notes_full( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1913,7 +1913,7 @@ async def test_not_release_notes( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1943,7 +1943,7 @@ async def test_no_os_entity( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1967,7 +1967,7 @@ async def test_setting_up_core_update_when_addon_fails( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() assert result diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 2749484cbcac..df4700467bce 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -389,7 +389,7 @@ async def test_update_addon( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -492,7 +492,7 @@ async def test_update_addon_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -635,7 +635,7 @@ async def test_update_addon_with_backup_removes_old_backups( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -698,7 +698,7 @@ async def test_update_core( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -793,7 +793,7 @@ async def test_update_core_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -832,7 +832,7 @@ async def test_update_addon_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -872,7 +872,7 @@ async def test_update_addon_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -911,7 +911,7 @@ async def test_update_core_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -939,7 +939,7 @@ async def test_update_core_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) From 4b90541e939fc3aed5ad99f534edf5e313498a1f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 3 Jul 2026 14:46:20 +0200 Subject: [PATCH 005/707] Implement auto-revert for pending HTTP config after a delay and update WebSocket API to include revert deadline (#174428) --- homeassistant/components/http/config.py | 70 +++++++++- .../components/http/websocket_api.py | 8 +- tests/components/http/test_init.py | 126 +++++++++++++++++- 3 files changed, 198 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py index 20952d51881c..ad5954743e29 100644 --- a/homeassistant/components/http/config.py +++ b/homeassistant/components/http/config.py @@ -1,6 +1,7 @@ """User-managed HTTP configuration store.""" import asyncio +from datetime import datetime, timedelta from ipaddress import IPv4Network, IPv6Network, ip_network import logging import os @@ -9,11 +10,13 @@ from typing import Any, Final, TypedDict, cast, override import voluptuous as vol from homeassistant.const import SERVER_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from .const import ( @@ -69,6 +72,8 @@ KEY_STABLE: Final = "stable" KEY_PENDING: Final = "pending" KEY_YAML_MIGRATION_DONE: Final = "yaml_migration_done" +AUTO_REVERT_DELAY: Final = timedelta(minutes=5) + DATA_STORE: HassKey[HTTPConfigStore] = HassKey(STORAGE_KEY) @@ -203,6 +208,7 @@ async def async_load_config(hass: HomeAssistant, config: ConfigType) -> ConfData if store.pending is not None: _LOGGER.info("Using pending HTTP config") + store.async_schedule_revert_to_stable() return store.pending _LOGGER.info("Using stable HTTP config") @@ -243,6 +249,8 @@ class HTTPConfigStore: self._yaml_migration_done = False self._loaded = False self._load_lock = asyncio.Lock() + self._revert_unsub: CALLBACK_TYPE | None = None + self._revert_deadline: datetime | None = None @property def stable(self) -> ConfData: @@ -254,6 +262,11 @@ class HTTPConfigStore: """Return the unconfirmed config awaiting promotion, if any.""" return self._pending + @property + def revert_deadline(self) -> datetime | None: + """Return when the pending config auto-reverts to stable, if scheduled.""" + return self._revert_deadline + @property def yaml_migration_done(self) -> bool: """Return whether the YAML migration has been completed.""" @@ -294,8 +307,63 @@ class HTTPConfigStore: raise HomeAssistantError("No pending HTTP config to promote") self._stable = self._pending self._pending = None + # The config is now confirmed; no need to revert it anymore. + self._async_cancel_revert() await self._async_persist() + @callback + def async_schedule_revert_to_stable(self) -> None: + """Schedule reverting the pending config back to stable. + + Loading a pending config is a trial. If the user does not promote it + within ``AUTO_REVERT_DELAY`` (e.g. because the new config made Home + Assistant unreachable), automatically clear it and restart so the last + known-good stable config is restored. + """ + self._async_cancel_revert() + self._revert_deadline = dt_util.utcnow() + AUTO_REVERT_DELAY + self._revert_unsub = async_call_later( + self._hass, + AUTO_REVERT_DELAY, + HassJob( + self._async_revert_to_stable, + "http config auto-revert", + cancel_on_shutdown=True, + ), + ) + + @callback + def _async_cancel_revert(self) -> None: + """Cancel a scheduled revert, if any. + + Also clears the deadline so ``revert_deadline`` no longer reports a + revert that will not happen (e.g. after the config is promoted). + """ + if self._revert_unsub is not None: + self._revert_unsub() + self._revert_unsub = None + self._revert_deadline = None + + async def _async_revert_to_stable(self, _now: datetime) -> None: + """Clear the unconfirmed pending config and restart to apply stable.""" + self._async_cancel_revert() + if self._pending is None: + return + _LOGGER.warning( + "Pending HTTP config was not confirmed within %s; reverting to the " + "stable config and restarting", + AUTO_REVERT_DELAY, + ) + self._pending = None + await self._async_persist() + # Imported here to avoid a circular import at module load time. + from homeassistant.components.homeassistant import ( # noqa: PLC0415 + DOMAIN as HASS_DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + ) + + await self._hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART) + async def async_migrate_yaml(self, config: ConfData) -> None: """Migrate YAML config to storage as pending if not the same as the config used for recovery.""" await self.async_load() diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py index 66537e61e303..9aff44f1f682 100644 --- a/homeassistant/components/http/websocket_api.py +++ b/homeassistant/components/http/websocket_api.py @@ -36,11 +36,17 @@ async def websocket_get_config( ``stable`` is the confirmed-working config ``pending`` is an unconfirmed config awaiting promotion, or ``None``. + ``revert_at`` is when an unconfirmed pending config auto-reverts to + stable, or ``None`` when no revert is scheduled. """ store = await async_get_and_load_store(hass) connection.send_result( msg["id"], - {"stable": store.stable, "pending": store.pending}, + { + "stable": store.stable, + "pending": store.pending, + "revert_at": store.revert_deadline, + }, ) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 6ca3a5056074..86d06218b600 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any from unittest.mock import ANY, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider @@ -17,6 +18,7 @@ from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.http import DOMAIN from homeassistant.components.http.config import ( _DEFAULT_CONFIG, + AUTO_REVERT_DELAY, HTTP_STORAGE_SCHEMA, default_server_port, ) @@ -27,9 +29,14 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_call_logger_set_level, async_mock_service +from tests.common import ( + async_call_logger_set_level, + async_fire_time_changed, + async_mock_service, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -1258,7 +1265,11 @@ async def test_websocket_http_config( await ws_client.send_json_auto_id({"type": "http/config"}) response = await ws_client.receive_json() assert response["success"] - assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": None} + assert response["result"] == { + "stable": _DEFAULT_CONFIG, + "pending": None, + "revert_at": None, + } new_config = { "server_port": 9123, @@ -1287,7 +1298,11 @@ async def test_websocket_http_config( await ws_client.send_json_auto_id({"type": "http/config"}) response = await ws_client.receive_json() assert response["success"] - assert response["result"] == {"stable": _DEFAULT_CONFIG, "pending": new_config} + assert response["result"] == { + "stable": _DEFAULT_CONFIG, + "pending": new_config, + "revert_at": None, + } # Promote: pending becomes stable, pending is cleared. await ws_client.send_json_auto_id({"type": "http/config/promote"}) @@ -1299,7 +1314,11 @@ async def test_websocket_http_config( await ws_client.send_json_auto_id({"type": "http/config"}) response = await ws_client.receive_json() assert response["success"] - assert response["result"] == {"stable": new_config, "pending": None} + assert response["result"] == { + "stable": new_config, + "pending": None, + "revert_at": None, + } # Promoting again with no pending is rejected. await ws_client.send_json_auto_id({"type": "http/config/promote"}) @@ -1338,6 +1357,105 @@ async def test_websocket_http_config( assert len(restart_calls) == 3 +async def test_pending_config_auto_reverts_to_stable( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """A loaded pending config reverts to stable if it is not confirmed in time.""" + hass_storage["http"] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + + # A revert clears the pending config and restarts to apply stable. + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + # The revert deadline is anchored to the (frozen) load time. + revert_at = dt_util.utcnow() + AUTO_REVERT_DELAY + + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + # While the unconfirmed pending config is active, a revert deadline is + # returned alongside it. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}), + "pending": HTTP_STORAGE_SCHEMA({"server_port": 9999}), + "revert_at": revert_at.isoformat(), + } + + # After the delay elapses without a promotion, pending is dropped and a + # restart is requested so the stable config is applied. + freezer.tick(AUTO_REVERT_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}), + "pending": None, + "yaml_migration_done": True, + } + assert len(restart_calls) == 1 + + +async def test_pending_config_promote_cancels_revert( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Promoting a pending config cancels the scheduled revert.""" + hass_storage["http"] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + # Confirm the pending config before the revert fires. + await ws_client.send_json_auto_id({"type": "http/config/promote"}) + response = await ws_client.receive_json() + assert response["success"] + + # The deadline is cleared once the config is confirmed. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}), + "pending": None, + "revert_at": None, + } + + # The cancelled revert must not fire after the delay. + freezer.tick(AUTO_REVERT_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}), + "pending": None, + "yaml_migration_done": True, + } + assert len(restart_calls) == 0 + + @pytest.mark.parametrize( "config", [ From fd9c7d4951b4812389a082ad090988180b80677b Mon Sep 17 00:00:00 2001 From: some-random-climber <293766853+some-random-climber@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:53:56 +0200 Subject: [PATCH 006/707] Move service registration to async_setup in Streamlabs Water (#175507) --- .../components/streamlabswater/__init__.py | 37 +++++----------- .../components/streamlabswater/services.py | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/streamlabswater/services.py diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index efbf973476bc..2279c14029b1 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -1,33 +1,29 @@ """Support for Streamlabs Water Monitor devices.""" from streamlabswater.streamlabswater import StreamlabsClient -import voluptuous as vol from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator - -ATTR_AWAY_MODE = "away_mode" -SERVICE_SET_AWAY_MODE = "set_away_mode" -AWAY_MODE_AWAY = "away" -AWAY_MODE_HOME = "home" - -CONF_LOCATION_ID = "location_id" +from .services import async_setup_services ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"} -SET_AWAY_MODE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), - vol.Optional(CONF_LOCATION_ID): cv.string, - } -) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool: """Set up StreamLabs from a config entry.""" @@ -41,17 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - def set_away_mode(service: ServiceCall) -> None: - """Set the StreamLabsWater Away Mode.""" - away_mode = service.data.get(ATTR_AWAY_MODE) - location_id = service.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0] - client.update_location(location_id, away_mode) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA - ) - return True diff --git a/homeassistant/components/streamlabswater/services.py b/homeassistant/components/streamlabswater/services.py new file mode 100644 index 000000000000..f015f6365cf7 --- /dev/null +++ b/homeassistant/components/streamlabswater/services.py @@ -0,0 +1,44 @@ +"""Services for Streamlabs Water.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN +from .coordinator import StreamlabsConfigEntry + +ATTR_AWAY_MODE = "away_mode" +SERVICE_SET_AWAY_MODE = "set_away_mode" +AWAY_MODE_AWAY = "away" +AWAY_MODE_HOME = "home" + +CONF_LOCATION_ID = "location_id" + +SET_AWAY_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(CONF_LOCATION_ID): cv.string, + } +) + + +def set_away_mode(call: ServiceCall) -> None: + """Set the StreamLabsWater Away Mode.""" + entry: StreamlabsConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, None + ) + coordinator = entry.runtime_data + coordinator.client.update_location( + call.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0], + call.data[ATTR_AWAY_MODE], + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + + hass.services.async_register( + DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA + ) From a1d80e72eec01ad9d624cec7f00965c0533d431d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Jul 2026 17:07:20 +0200 Subject: [PATCH 007/707] Remove not needed major version block for samsungtv migration (#175496) --- homeassistant/components/samsungtv/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index c1126c552f49..449c0722bde1 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -239,10 +239,6 @@ async def async_migrate_entry( version = config_entry.version minor_version = config_entry.minor_version - if version > 2: - # This means the user has downgraded from a future version - return False - LOGGER.debug("Migrating from version %s.%s", version, minor_version) # 1 -> 2: Unique ID format changed, so delete and re-import: From 9910dd5d17caa57e432d72dbbf371e560ce71288 Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 4 Jul 2026 01:20:10 +1000 Subject: [PATCH 008/707] Infrared fix naming by device class for derived entities (#175434) --- homeassistant/components/infrared/entity.py | 16 +++ .../components/infrared/strings.json | 2 +- tests/components/infrared/common.py | 10 +- tests/components/infrared/test_init.py | 104 +++++++++++++++++- 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/infrared/entity.py b/homeassistant/components/infrared/entity.py index 473c3a0d2a32..5fa7c085c9bd 100644 --- a/homeassistant/components/infrared/entity.py +++ b/homeassistant/components/infrared/entity.py @@ -49,6 +49,14 @@ class InfraredEmitterEntity(RestoreEntity): __last_command_sent: str | None = None + @override + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For infrared emitters this is True if the entity has a device class. + """ + return self.device_class is not None + @property @final @override @@ -101,6 +109,14 @@ class InfraredReceiverEntity(RestoreEntity): __last_signal_received: str | None = None + @override + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For infrared receivers this is True if the entity has a device class. + """ + return self.device_class is not None + @cached_property def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]: """Subscriber callback set, lazily initialized on first access.""" diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json index 09d705d53cc0..3a21a72eac63 100644 --- a/homeassistant/components/infrared/strings.json +++ b/homeassistant/components/infrared/strings.json @@ -1,6 +1,6 @@ { "entity_component": { - "_": { + "emitter": { "name": "Infrared emitter" }, "receiver": { diff --git a/tests/components/infrared/common.py b/tests/components/infrared/common.py index 793dcc681d07..a5a8be790fbb 100644 --- a/tests/components/infrared/common.py +++ b/tests/components/infrared/common.py @@ -33,11 +33,12 @@ class MockInfraredEmitterEntity(InfraredEmitterEntity): """Mock infrared emitter entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR emitter" - def __init__(self, unique_id: str) -> None: + def __init__(self, unique_id: str, name: str | None = "Test IR emitter") -> None: """Initialize mock entity.""" self._attr_unique_id = unique_id + if name is not None: + self._attr_name = name self.send_command_calls: list[InfraredCommand] = [] async def async_send_command(self, command: InfraredCommand) -> None: @@ -49,11 +50,12 @@ class MockInfraredReceiverEntity(InfraredReceiverEntity): """Mock infrared receiver entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR receiver" - def __init__(self, unique_id: str) -> None: + def __init__(self, unique_id: str, name: str | None = "Test IR receiver") -> None: """Initialize mock receiver entity.""" self._attr_unique_id = unique_id + if name is not None: + self._attr_name = name async def init_infrared_fixture_helper(hass: HomeAssistant) -> None: diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index 71d7d96040d6..902f1035877d 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -16,15 +16,27 @@ from homeassistant.components.infrared import ( async_send_command, async_subscribe_receiver, ) -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import MockInfraredEmitterEntity, MockInfraredReceiverEntity -from tests.common import mock_restore_cache +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) + +TEST_DOMAIN = "test" TEST_COMMAND = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) @@ -293,3 +305,91 @@ async def test_async_subscribe_receiver_component_not_loaded( """Test async_subscribe_receiver raises error when component not loaded.""" with pytest.raises(HomeAssistantError, match="component_not_loaded"): async_subscribe_receiver(hass, "infrared.some_entity", lambda _: None) + + +@pytest.mark.usefixtures("init_infrared") +async def test_name(hass: HomeAssistant) -> None: + """Test entity name / device class naming fallback.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.INFRARED] + ) + return True + + class MockFlow(ConfigFlow): + """Test flow.""" + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed emitter without has_entity_name -> no name + emitter1 = MockInfraredEmitterEntity("test_emitter1", name=None) + emitter1.entity_id = "infrared.test_emitter1" + emitter1._attr_has_entity_name = False + + # Unnamed emitter with has_entity_name True -> name set from device class + emitter2 = MockInfraredEmitterEntity("test_emitter2", name=None) + emitter2.entity_id = "infrared.test_emitter2" + emitter2._attr_has_entity_name = True + + # Unnamed receiver without has_entity_name -> no name + receiver1 = MockInfraredReceiverEntity("test_receiver1", name=None) + receiver1.entity_id = "infrared.test_receiver1" + receiver1._attr_has_entity_name = False + + # Unnamed receiver with has_entity_name True -> name set from device class + receiver2 = MockInfraredReceiverEntity("test_receiver2", name=None) + receiver2.entity_id = "infrared.test_receiver2" + receiver2._attr_has_entity_name = True + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test infrared platform via config entry.""" + async_add_entities([emitter1, emitter2, receiver1, receiver2]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + with mock_config_flow(TEST_DOMAIN, MockFlow): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state1 = hass.states.get("infrared.test_emitter1") + assert state1 is not None + assert state1.attributes == {"device_class": "emitter"} + + state2 = hass.states.get("infrared.test_emitter2") + assert state2 is not None + assert state2.attributes == { + "device_class": "emitter", + "friendly_name": "Infrared emitter", + } + + state3 = hass.states.get("infrared.test_receiver1") + assert state3 is not None + assert state3.attributes == {"device_class": "receiver"} + + state4 = hass.states.get("infrared.test_receiver2") + assert state4 is not None + assert state4.attributes == { + "device_class": "receiver", + "friendly_name": "Infrared receiver", + } From 92cc2fc65038501d6f72d7ca3c56557ee4b88129 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jul 2026 17:58:19 +0200 Subject: [PATCH 009/707] Classify scene, script and group entities in device and config entry search (#175461) --- homeassistant/components/search/__init__.py | 6 +++++ tests/components/search/test_init.py | 29 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 214da9aacb16..a0ce1c69c136 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -327,6 +327,12 @@ class Searcher: # Add labels of this entity self._add(ItemType.LABEL, entity_entry.labels) + if not entry_point: + # If this entity also exists as a resource, we add it. + domain = split_entity_id(entity_id)[0] + if domain in self.EXIST_AS_ENTITY: + self._add(ItemType(domain), entity_id) + # Automations referencing this entity self._add( ItemType.AUTOMATION, diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index df99522f9dde..a3b78d39d61e 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -111,6 +111,23 @@ async def test_search( wled_segment_2_entity.entity_id, area_id=bedroom_area.id ) + # Config entry with a device providing a scene entity + esphome_config_entry = MockConfigEntry(domain="esphome") + esphome_config_entry.add_to_hass(hass) + esphome_device = device_registry.async_get_or_create( + config_entry_id=esphome_config_entry.entry_id, + name="Node", + identifiers={("esphome", "esphome-1")}, + ) + esphome_scene_entity = entity_registry.async_get_or_create( + "scene", + "esphome", + "esphome-1-scene", + suggested_object_id="esphome scene", + config_entry=esphome_config_entry, + device_id=esphome_device.id, + ) + scene_wled_hue_entity = entity_registry.async_get_or_create( "scene", "homeassistant", @@ -658,6 +675,18 @@ async def test_search( ItemType.SCENE: {"scene.scene_hue_seg_1", scene_wled_hue_entity.entity_id}, ItemType.SCRIPT: {"script.device", "script.hue"}, } + assert search(ItemType.DEVICE, esphome_device.id) == { + ItemType.CONFIG_ENTRY: {esphome_config_entry.entry_id}, + ItemType.ENTITY: {esphome_scene_entity.entity_id}, + ItemType.INTEGRATION: {"esphome"}, + ItemType.SCENE: {esphome_scene_entity.entity_id}, + } + assert search(ItemType.CONFIG_ENTRY, esphome_config_entry.entry_id) == { + ItemType.DEVICE: {esphome_device.id}, + ItemType.ENTITY: {esphome_scene_entity.entity_id}, + ItemType.INTEGRATION: {"esphome"}, + ItemType.SCENE: {esphome_scene_entity.entity_id}, + } assert not search(ItemType.ENTITY, "sensor.unknown") assert search(ItemType.ENTITY, wled_segment_1_entity.entity_id) == { From 0a9534a9275ef159f2e1ac6f596c37153c64d786 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jul 2026 18:05:43 +0200 Subject: [PATCH 010/707] Resolve person member entities in search (#175462) --- homeassistant/components/search/__init__.py | 8 +++---- tests/components/search/test_init.py | 23 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index a0ce1c69c136..22d749d7cb59 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -426,7 +426,7 @@ class Searcher: @callback def _async_search_person(self, person_entity_id: str) -> None: """Find results for a person.""" - # Up resolve the scene entity itself + # Up resolve the person entity itself if entity_entry := self._async_resolve_up_entity(person_entity_id): # Add labels of this person entity self._add(ItemType.LABEL, entity_entry.labels) @@ -443,9 +443,9 @@ class Searcher: ) # Add all member entities of this person - self._add( - ItemType.ENTITY, person.entities_in_person(self.hass, person_entity_id) - ) + for entity_id in person.entities_in_person(self.hass, person_entity_id): + self._add(ItemType.ENTITY, entity_id) + self._async_resolve_up_entity(entity_id) @callback def _async_search_scene(self, scene_entity_id: str) -> None: diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index a3b78d39d61e..54186cad4f94 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -153,6 +153,23 @@ async def test_search( labels={label_other.label_id}, ) + # Device tracker of the person, provided by a config entry with a device + mobile_app_config_entry = MockConfigEntry(domain="mobile_app") + mobile_app_config_entry.add_to_hass(hass) + mobile_app_device = device_registry.async_get_or_create( + config_entry_id=mobile_app_config_entry.entry_id, + name="Paulus iPhone", + identifiers={("mobile_app", "phone-1")}, + ) + entity_registry.async_get_or_create( + "device_tracker", + "mobile_app", + "phone-1-tracker", + suggested_object_id="paulus_iphone", + config_entry=mobile_app_config_entry, + device_id=mobile_app_device.id, + ) + script_scene_entity = entity_registry.async_get_or_create( "script", "script", @@ -754,6 +771,9 @@ async def test_search( ItemType.SCRIPT: {script_scene_entity.entity_id}, } assert search(ItemType.ENTITY, "device_tracker.paulus_iphone") == { + ItemType.CONFIG_ENTRY: {mobile_app_config_entry.entry_id}, + ItemType.DEVICE: {mobile_app_device.id}, + ItemType.INTEGRATION: {"mobile_app"}, ItemType.PERSON: {person_paulus_entity.entity_id}, } assert search(ItemType.ENTITY, "light.wled_config_entry_source") == { @@ -869,8 +889,11 @@ async def test_search( assert not search(ItemType.PERSON, "person.unknown") assert search(ItemType.PERSON, person_paulus_entity.entity_id) == { ItemType.AREA: {bedroom_area.id}, + ItemType.CONFIG_ENTRY: {mobile_app_config_entry.entry_id}, + ItemType.DEVICE: {mobile_app_device.id}, ItemType.ENTITY: {"device_tracker.paulus_iphone"}, ItemType.FLOOR: {second_floor.floor_id}, + ItemType.INTEGRATION: {"mobile_app"}, ItemType.LABEL: {label_other.label_id}, } From d16f71c8cc0d569e87670907cf84470cb15e5bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 3 Jul 2026 17:15:59 +0100 Subject: [PATCH 011/707] Use Radon device class in Airthings BLE (#175388) --- .../components/airthings_ble/const.py | 3 -- .../components/airthings_ble/coordinator.py | 5 +-- .../components/airthings_ble/icons.json | 6 ---- .../components/airthings_ble/sensor.py | 33 ++++++------------- 4 files changed, 11 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py index 43b6268bd093..195bd0e74cfa 100644 --- a/homeassistant/components/airthings_ble/const.py +++ b/homeassistant/components/airthings_ble/const.py @@ -5,9 +5,6 @@ from airthings_ble import AirthingsDeviceType DOMAIN = "airthings_ble" MFCT_ID = 820 -VOLUME_BECQUEREL = "Bq/m³" -VOLUME_PICOCURIE = "pCi/L" - DEVICE_MODEL = "device_model" DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index ca580483e373..7c7284f7e751 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( DEFAULT_SCAN_INTERVAL, @@ -36,9 +35,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]): def __init__(self, hass: HomeAssistant, entry: AirthingsBLEConfigEntry) -> None: """Initialize the coordinator.""" - self.airthings = AirthingsBluetoothDeviceData( - _LOGGER, hass.config.units is METRIC_SYSTEM - ) + self.airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric=True) device_model = entry.data.get(DEVICE_MODEL) interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get( diff --git a/homeassistant/components/airthings_ble/icons.json b/homeassistant/components/airthings_ble/icons.json index 04b951999baf..600e0d3f6e77 100644 --- a/homeassistant/components/airthings_ble/icons.json +++ b/homeassistant/components/airthings_ble/icons.json @@ -9,15 +9,9 @@ "smartlink": "mdi:hub" } }, - "radon_1day_avg": { - "default": "mdi:radioactive" - }, "radon_1day_level": { "default": "mdi:radioactive" }, - "radon_longterm_avg": { - "default": "mdi:radioactive" - }, "radon_longterm_level": { "default": "mdi:radioactive" } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index b707e8c2a921..afeaacc62f16 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -1,7 +1,6 @@ """Support for airthings ble sensors.""" from collections.abc import Callable -import dataclasses from dataclasses import dataclass import logging from typing import override @@ -19,6 +18,7 @@ from homeassistant.const import ( EntityCategory, Platform, UnitOfPressure, + UnitOfRadiationConcentration, UnitOfRatio, UnitOfSoundPressure, UnitOfTemperature, @@ -33,9 +33,8 @@ from homeassistant.helpers.entity_registry import ( ) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE +from .const import DOMAIN from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -65,15 +64,15 @@ SENSORS_MAPPING_TEMPLATE: dict[str, AirthingsBLESensorEntityDescription] = { "radon_1day_avg": AirthingsBLESensorEntityDescription( key="radon_1day_avg", translation_key="radon_1day_avg", - native_unit_of_measurement=VOLUME_BECQUEREL, - suggested_display_precision=0, + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), "radon_longterm_avg": AirthingsBLESensorEntityDescription( key="radon_longterm_avg", translation_key="radon_longterm_avg", - native_unit_of_measurement=VOLUME_BECQUEREL, - suggested_display_precision=0, + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), "radon_1day_level": AirthingsBLESensorEntityDescription( @@ -210,26 +209,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airthings BLE sensors.""" - is_metric = hass.config.units is METRIC_SYSTEM - coordinator = entry.runtime_data - # we need to change some units - sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() - if not is_metric: - for key, val in sensors_mapping.items(): - if val.native_unit_of_measurement is not VOLUME_BECQUEREL: - continue - sensors_mapping[key] = dataclasses.replace( - val, - native_unit_of_measurement=VOLUME_PICOCURIE, - suggested_display_precision=1, - ) - entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) for sensor_type, sensor_value in coordinator.data.sensors.items(): - if sensor_type not in sensors_mapping: + if sensor_type not in SENSORS_MAPPING_TEMPLATE: _LOGGER.debug( "Unknown sensor type detected: %s, %s", sensor_type, @@ -238,7 +223,9 @@ async def async_setup_entry( continue async_migrate(hass, coordinator.data.address, sensor_type) entities.append( - AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) + AirthingsSensor( + coordinator, coordinator.data, SENSORS_MAPPING_TEMPLATE[sensor_type] + ) ) async_add_entities(entities) From 02aaf56dc1dcfb782e02b10c5368f29819995fd5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Jul 2026 18:26:49 +0200 Subject: [PATCH 012/707] Resolve up labeled areas, devices and entities in search (#175464) --- homeassistant/components/search/__init__.py | 3 +++ tests/components/search/test_init.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 22d749d7cb59..2dd93008fd4d 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -400,14 +400,17 @@ class Searcher: # Areas with this label for area_entry in ar.async_entries_for_label(self._area_registry, label_id): self._add(ItemType.AREA, area_entry.id) + self._async_resolve_up_area(area_entry.id) # Devices with this label for device in dr.async_entries_for_label(self._device_registry, label_id): self._add(ItemType.DEVICE, device.id) + self._async_resolve_up_device(device.id) # Entities with this label for entity_entry in er.async_entries_for_label(self._entity_registry, label_id): self._add(ItemType.ENTITY, entity_entry.entity_id) + self._async_resolve_up_entity(entity_entry.entity_id) # If this entity also exists as a resource, we add it. domain = split_entity_id(entity_entry.entity_id)[0] diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 54186cad4f94..3352250630cf 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -868,11 +868,20 @@ async def test_search( assert not search(ItemType.LABEL, "unknown") assert search(ItemType.LABEL, label_christmas.label_id) == { + ItemType.AREA: {living_room_area.id}, ItemType.AUTOMATION: {"automation.label"}, + ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.DEVICE: {wled_device.id}, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.INTEGRATION: {"wled"}, } assert search(ItemType.LABEL, label_energy.label_id) == { + ItemType.AREA: {kitchen_area.id}, + ItemType.CONFIG_ENTRY: {hue_config_entry.entry_id}, + ItemType.DEVICE: {hue_device.id}, ItemType.ENTITY: {hue_segment_1_entity.entity_id}, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.INTEGRATION: {"hue"}, } assert search(ItemType.LABEL, label_other.label_id) == { ItemType.AREA: {bedroom_area.id}, @@ -881,6 +890,7 @@ async def test_search( person_paulus_entity.entity_id, script_scene_entity.entity_id, }, + ItemType.FLOOR: {second_floor.floor_id}, ItemType.PERSON: {person_paulus_entity.entity_id}, ItemType.SCENE: {scene_wled_hue_entity.entity_id}, ItemType.SCRIPT: {"script.label", script_scene_entity.entity_id}, From 89a92be9d7b7d588ea655ad860282aaa802dccee Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 4 Jul 2026 02:49:18 +1000 Subject: [PATCH 013/707] Add seat coolers to Teslemetry (#175422) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/teslemetry/select.py | 27 ++++++++ .../components/teslemetry/strings.json | 18 ++++++ tests/components/teslemetry/test_select.py | 64 ++++++++++++++++++- 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 2803c71ccf6b..36aed04573f7 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -176,6 +176,33 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( HIGH, ], ), + TeslemetrySelectEntityDescription( + # remote_seat_cooler_request uses 1-indexed positions (front-left=1, + # front-right=2), unlike the 0-indexed Seat enum used for heaters. + # Polled state comes from the seat_fan_front_* vehicle_data fields. + key="climate_state_seat_fan_front_left", + select_fn=lambda api, level: api.remote_seat_cooler_request(1, level), + supported_fn=lambda data: bool(data.get("has_seat_cooling")), + streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontLeft(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), + TeslemetrySelectEntityDescription( + key="climate_state_seat_fan_front_right", + select_fn=lambda api, level: api.remote_seat_cooler_request(2, level), + supported_fn=lambda data: bool(data.get("has_seat_cooling")), + streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontRight(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), ) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index ffb9e9d9ccbd..cc39dad96bef 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -363,6 +363,24 @@ } }, "select": { + "climate_state_seat_fan_front_left": { + "name": "Seat cooler front left", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "climate_state_seat_fan_front_right": { + "name": "Seat cooler front right", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index a7e58d525ba2..7c2ec7a34f6d 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -16,7 +16,7 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, ) from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL -from homeassistant.components.teslemetry.select import LOW +from homeassistant.components.teslemetry.select import LEVEL, LOW, MEDIUM, OFF from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -183,6 +183,68 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: call.assert_called_once() +@pytest.mark.parametrize( + ("entity_id", "seat_position"), + [ + ("select.test_seat_cooler_front_left", 1), + ("select.test_seat_cooler_front_right", 2), + ], +) +async def test_seat_cooler_services( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, + entity_id: str, + seat_position: int, +) -> None: + """Test the seat cooler entities send the 1-indexed seat position. + + remote_seat_cooler_request is 1-indexed (front-left=1, front-right=2), + unlike the 0-indexed Seat enum used for the seat heaters. + """ + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + metadata = deepcopy(METADATA) + metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} + mock_metadata.return_value = metadata + + await setup_platform(hass, [Platform.SELECT]) + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remote_seat_cooler_request", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW}, + blocking=True, + ) + assert hass.states.get(entity_id).state == LOW + call.assert_called_once_with(seat_position, LEVEL[LOW]) + + +async def test_seat_cooler_polling( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, +) -> None: + """Test the seat cooler entities read polled state from seat_fan_front_*.""" + metadata = deepcopy(METADATA) + metadata["vehicles"][VEHICLE_VIN]["polling"] = True + metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} + mock_metadata.return_value = metadata + + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["seat_fan_front_left"] = 2 + data["response"]["climate_state"]["seat_fan_front_right"] = 0 + mock_vehicle_data.return_value = data + + await setup_platform(hass, [Platform.SELECT]) + + assert hass.states.get("select.test_seat_cooler_front_left").state == MEDIUM + assert hass.states.get("select.test_seat_cooler_front_right").state == OFF + + @pytest.mark.parametrize("response", COMMAND_ERRORS) async def test_select_command_errors( hass: HomeAssistant, mock_vehicle_data: AsyncMock, response: dict From 9f22cdfc635505626141b67d304f12ea32612e51 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Jul 2026 19:05:41 +0200 Subject: [PATCH 014/707] Remove not needed major version guard for knx config entry migration (#175498) --- homeassistant/components/knx/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index a1ec1e84547f..6ae46c3173be 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -165,10 +165,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 2: - # Don't migrate from future version - return False - if entry.version == 1: new_data = {**entry.data} new_options = {**entry.options} From 715a8984e475ffbea0f889c600e5c5d706d5197a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Jul 2026 19:06:07 +0200 Subject: [PATCH 015/707] Remove not needed major version guard for switchbot config entry migration (#175497) --- homeassistant/components/switchbot/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index b97cfda1ea93..3223f24fa027 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -427,9 +427,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) minor_version = entry.minor_version _LOGGER.debug("Migrating from version %s.%s", version, minor_version) - if version > 1: - return False - if version == 1 and minor_version < 2: new_options: dict[str, Any] = {**entry.options} From f2dfafb5a817293af34868a274594daf29898527 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 4 Jul 2026 03:06:58 +1000 Subject: [PATCH 016/707] Use evohome constants for evohome attrs (#175494) --- homeassistant/components/evohome/climate.py | 32 ++++----- homeassistant/components/evohome/const.py | 4 -- homeassistant/components/evohome/entity.py | 5 +- homeassistant/components/evohome/services.py | 44 ++++++------- tests/components/evohome/test_services.py | 68 +++++++++----------- tests/components/evohome/test_storage.py | 20 +++--- 6 files changed, 80 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 032eef86b9ff..534e64ae5a7a 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -6,10 +6,14 @@ from typing import Any, override import evohomeasync2 as evo from evohomeasync2.const import ( + SZ_DURATION, + SZ_MODE, + SZ_PERIOD, SZ_SETPOINT_STATUS, SZ_SYSTEM_MODE, SZ_SYSTEM_MODE_STATUS, SZ_TEMPERATURE_STATUS, + SZ_UNTIL, SystemMode as EvoSystemMode, ZoneMode as EvoZoneMode, ) @@ -23,12 +27,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ( - ATTR_MODE, - ATTR_TEMPERATURE, - PRECISION_TENTHS, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -36,14 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import ( - ATTR_DURATION, - ATTR_PERIOD, - DOMAIN, - EVOHOME_DATA, - RESET_BREAKS_IN_HA_VERSION, - EvoService, -) +from .const import DOMAIN, EVOHOME_DATA, RESET_BREAKS_IN_HA_VERSION, EvoService from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id from .helpers import async_create_deprecation_issue_once @@ -270,7 +262,7 @@ class EvoZone(EvoChild, EvoClimateEntity): temperature = kwargs[ATTR_TEMPERATURE] - if (until := kwargs.get("until")) is None: + if (until := kwargs.get(SZ_UNTIL)) is None: if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: until = self._evo_device.until if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: @@ -395,14 +387,14 @@ class EvoController(EvoClimateEntity): await self.coordinator.call_client_api(self._evo_device.reset()) return - mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE + mode = data[SZ_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE - if ATTR_PERIOD in data: + if SZ_PERIOD in data: until = dt_util.start_of_local_day() - until += data[ATTR_PERIOD] + until += data[SZ_PERIOD] - elif ATTR_DURATION in data: - until = dt_util.now() + data[ATTR_DURATION] + elif SZ_DURATION in data: + until = dt_util.now() + data[SZ_DURATION] else: until = None diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 06baf09cfc4c..3023b9081455 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -20,10 +20,6 @@ CONF_LOCATION_IDX: Final = "location_idx" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) -ATTR_DURATION: Final = "duration" # number of minutes, <24h -ATTR_PERIOD: Final = "period" # number of days -ATTR_SETPOINT: Final = "setpoint" - # Support for the refresh_system service is being deprecated REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0" # Support for the reset service calls/presets is being deprecated diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index dcfa47602df3..3435bf0e19b2 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -8,6 +8,9 @@ from typing import Any, override import evohomeasync2 as evo from evohomeasync2.const import ( + SZ_SINCE, + SZ_TIME_UNTIL, + SZ_UNTIL, ZoneModelType as EvoZoneModelType, ZoneType as EvoZoneType, ) @@ -28,7 +31,7 @@ def _recurse_and_revert(val: Any, _key: str | None = None) -> Any: return {k: _recurse_and_revert(v, k) for k, v in val.items()} if isinstance(val, (list, tuple)): return type(val)(_recurse_and_revert(v) for v in val) - if isinstance(val, datetime) and _key in ("since", "time_until", "until"): + if isinstance(val, datetime) and _key in (SZ_SINCE, SZ_TIME_UNTIL, SZ_UNTIL): return val.isoformat() if isinstance(val, StrEnum): return "".join(word.capitalize() for word in val.value.split("_")) diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index a95d528b7d53..1c4e1344bd18 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -8,7 +8,10 @@ from evohomeasync2 import ControlSystem from evohomeasync2.const import ( SZ_CAN_BE_TEMPORARY, SZ_DURATION, + SZ_MODE, SZ_PERIOD, + SZ_SETPOINT, + SZ_STATE, SZ_SYSTEM_MODE, SZ_TIMING_MODE, ) @@ -16,7 +19,7 @@ import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( @@ -28,9 +31,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control from .const import ( - ATTR_DURATION, - ATTR_PERIOD, - ATTR_SETPOINT, DOMAIN, REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, @@ -49,12 +49,12 @@ def _as_snake_case(mode: str) -> str: # System service schemas (registered as domain services) SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # unsupported modes are rejected at runtime with ServiceValidationError - vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here - vol.Exclusive(ATTR_DURATION, "temporary"): vol.All( + vol.Required(SZ_MODE): cv.string, # ... so, don't use SystemMode enum here + vol.Exclusive(SZ_DURATION, "temporary"): vol.All( cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), ), - vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All( + vol.Exclusive(SZ_PERIOD, "temporary"): vol.All( cv.time_period, vol.Range(min=timedelta(days=1), max=timedelta(days=99)), ), @@ -63,10 +63,8 @@ SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # Zone service schemas (registered as entity services) SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { - vol.Required(ATTR_SETPOINT): vol.All( - vol.Coerce(float), vol.Range(min=4.0, max=35.0) - ), - vol.Optional(ATTR_DURATION): vol.All( + vol.Required(SZ_SETPOINT): vol.All(vol.Coerce(float), vol.Range(min=4.0, max=35.0)), + vol.Optional(SZ_DURATION): vol.All( cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1)), ), @@ -74,8 +72,8 @@ SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # DHW service schemas (registered as entity services) SET_DHW_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { - vol.Required(ATTR_STATE): cv.boolean, - vol.Optional(ATTR_DURATION): vol.All( + vol.Required(SZ_STATE): cv.boolean, + vol.Optional(SZ_DURATION): vol.All( cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1)), ), @@ -163,7 +161,7 @@ def _register_dhw_entity_services(hass: HomeAssistant) -> None: def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None: """Validate that a set_system_mode service call is properly formed.""" - mode = data[ATTR_MODE] + mode = data[SZ_MODE] tcs_modes = {m[SZ_SYSTEM_MODE].value: m for m in tcs.allowed_system_modes} # Validation occurs here, instead of in the library, because it uses a slightly @@ -174,34 +172,34 @@ def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) - raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_not_supported", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) # voluptuous schema ensures that duration and period are not both present if not mode_info[SZ_CAN_BE_TEMPORARY]: - if ATTR_DURATION in data or ATTR_PERIOD in data: + if SZ_DURATION in data or SZ_PERIOD in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_be_temporary", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) return timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary - if timing_mode == SZ_DURATION and ATTR_PERIOD in data: + if timing_mode == SZ_DURATION and SZ_PERIOD in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_have_period", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) - if timing_mode == SZ_PERIOD and ATTR_DURATION in data: + if timing_mode == SZ_PERIOD and SZ_DURATION in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_have_duration", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) @@ -252,8 +250,8 @@ def setup_service_functions( payload = { "unique_id": unique_id, "service": call.service, - "data": {**call.data, ATTR_MODE: _as_snake_case(call.data[ATTR_MODE])} - if ATTR_MODE in call.data + "data": {**call.data, SZ_MODE: _as_snake_case(call.data[SZ_MODE])} + if SZ_MODE in call.data else call.data, } async_dispatcher_send(hass, DOMAIN, payload) diff --git a/tests/components/evohome/test_services.py b/tests/components/evohome/test_services.py index 91a911e1de49..eab9cbf81515 100644 --- a/tests/components/evohome/test_services.py +++ b/tests/components/evohome/test_services.py @@ -4,15 +4,13 @@ from datetime import UTC, datetime from typing import Any from unittest.mock import patch +from evohomeasync2.const import SZ_DURATION, SZ_MODE, SZ_PERIOD, SZ_SETPOINT, SZ_STATE from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.evohome.climate import EvoZone from homeassistant.components.evohome.const import ( - ATTR_DURATION, - ATTR_PERIOD, - ATTR_SETPOINT, DOMAIN, REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, @@ -21,7 +19,7 @@ from homeassistant.components.evohome.const import ( ) from homeassistant.components.evohome.water_heater import EvoDHW from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir @@ -133,7 +131,7 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", }, blocking=True, ) @@ -156,8 +154,8 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "AutoWithEco", - ATTR_DURATION: {"hours": 12}, + SZ_MODE: "AutoWithEco", + SZ_DURATION: {"hours": 12}, }, blocking=True, ) @@ -172,8 +170,8 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, blocking=True, ) @@ -199,8 +197,8 @@ async def test_set_system_mode( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, target={ATTR_ENTITY_ID: ctl_id}, blocking=True, @@ -217,8 +215,8 @@ async def test_set_system_mode( EvoService.SET_SYSTEM_MODE, { ATTR_ENTITY_ID: ctl_id, - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, blocking=True, ) @@ -308,7 +306,7 @@ async def test_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, + SZ_SETPOINT: 19.5, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -322,8 +320,8 @@ async def test_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 135}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 135}, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -363,8 +361,8 @@ async def test_set_zone_override_advance( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 0}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 0}, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -392,7 +390,7 @@ async def test_set_zone_override_legacy( EvoService.SET_ZONE_OVERRIDE, { ATTR_ENTITY_ID: zone_id, - ATTR_SETPOINT: 19.5, + SZ_SETPOINT: 19.5, }, blocking=True, ) @@ -406,8 +404,8 @@ async def test_set_zone_override_legacy( EvoService.SET_ZONE_OVERRIDE, { ATTR_ENTITY_ID: zone_id, - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 135}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 135}, }, blocking=True, ) @@ -422,7 +420,7 @@ async def test_set_zone_override_legacy( ("service", "service_data"), [ (EvoService.CLEAR_ZONE_OVERRIDE, {}), - (EvoService.SET_ZONE_OVERRIDE, {ATTR_SETPOINT: 19.5}), + (EvoService.SET_ZONE_OVERRIDE, {SZ_SETPOINT: 19.5}), ], ) async def test_zone_services_with_ctl_id( @@ -458,7 +456,7 @@ async def test_controller_services_with_zone_id( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", ATTR_ENTITY_ID: zone_id, }, blocking=True, @@ -482,7 +480,7 @@ async def test_set_system_mode_entity_not_found(hass: HomeAssistant) -> None: DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", ATTR_ENTITY_ID: non_existent_entity_id, }, blocking=True, @@ -496,19 +494,19 @@ async def test_set_system_mode_entity_not_found(hass: HomeAssistant) -> None: _SET_SYSTEM_MODE_VALIDATOR_PARAMS = [ ( - {ATTR_MODE: "NotARealMode"}, + {SZ_MODE: "NotARealMode"}, "mode_not_supported", ), ( - {ATTR_MODE: "Auto", ATTR_DURATION: {"hours": 1}}, + {SZ_MODE: "Auto", SZ_DURATION: {"hours": 1}}, "mode_cant_be_temporary", ), ( - {ATTR_MODE: "AutoWithEco", ATTR_PERIOD: {"days": 1}}, + {SZ_MODE: "AutoWithEco", SZ_PERIOD: {"days": 1}}, "mode_cant_have_period", ), ( - {ATTR_MODE: "DayOff", ATTR_DURATION: {"hours": 1}}, + {SZ_MODE: "DayOff", SZ_DURATION: {"hours": 1}}, "mode_cant_have_duration", ), ] @@ -537,9 +535,7 @@ async def test_set_system_mode_validator( ) assert exc_info.value.translation_key == expected_translation_key - assert exc_info.value.translation_placeholders == { - ATTR_MODE: service_data[ATTR_MODE] - } + assert exc_info.value.translation_placeholders == {SZ_MODE: service_data[SZ_MODE]} @pytest.mark.parametrize("install", ["default"]) @@ -558,7 +554,7 @@ async def test_set_dhw_override( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: False, + SZ_STATE: False, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, @@ -572,8 +568,8 @@ async def test_set_dhw_override( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: True, - ATTR_DURATION: {"minutes": 135}, + SZ_STATE: True, + SZ_DURATION: {"minutes": 135}, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, @@ -613,8 +609,8 @@ async def test_set_dhw_override_advance( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: True, - ATTR_DURATION: {"minutes": 0}, + SZ_STATE: True, + SZ_DURATION: {"minutes": 0}, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 49910ea1b251..792cc7ba15b5 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -3,9 +3,15 @@ from datetime import datetime, timedelta from typing import Any, Final, NotRequired, TypedDict +from evohomeasync2.auth import ( + SZ_ACCESS_TOKEN, + SZ_ACCESS_TOKEN_EXPIRES, + SZ_REFRESH_TOKEN, +) import pytest from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -30,10 +36,6 @@ class _EmptyStoreT(TypedDict): pass -SZ_USERNAME: Final = "username" -SZ_REFRESH_TOKEN: Final = "refresh_token" -SZ_ACCESS_TOKEN: Final = "access_token" -SZ_ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" SZ_USER_DATA: Final = "user_data" @@ -49,7 +51,7 @@ USERNAME_DIFF: Final = f"not_{USERNAME}" USERNAME_SAME: Final = USERNAME _TEST_STORAGE_BASE: Final[_TokenStoreT] = { - SZ_USERNAME: USERNAME_SAME, + CONF_USERNAME: USERNAME_SAME, SZ_REFRESH_TOKEN: REFRESH_TOKEN, SZ_ACCESS_TOKEN: ACCESS_TOKEN, SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR, @@ -92,7 +94,7 @@ async def test_auth_tokens_null( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + assert data[CONF_USERNAME] == USERNAME_SAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( @@ -120,7 +122,7 @@ async def test_auth_tokens_same( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + assert data[CONF_USERNAME] == USERNAME_SAME assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN assert data[SZ_ACCESS_TOKEN] == ACCESS_TOKEN assert dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES]) == ACCESS_TOKEN_EXP_DTM @@ -151,7 +153,7 @@ async def test_auth_tokens_past( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + assert data[CONF_USERNAME] == USERNAME_SAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( @@ -180,7 +182,7 @@ async def test_auth_tokens_diff( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_DIFF + assert data[CONF_USERNAME] == USERNAME_DIFF assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( From fd4e1ee47c8142372284fbb7164722b0da8bfa6c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 3 Jul 2026 19:08:05 +0200 Subject: [PATCH 017/707] Move RFXtrx services to async_setup (#175474) --- homeassistant/components/rfxtrx/__init__.py | 39 +++++++-------------- homeassistant/components/rfxtrx/services.py | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/rfxtrx/services.py diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 90393589263c..e405aadfe06e 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -7,7 +7,6 @@ import logging from typing import Any, NamedTuple, cast import RFXtrx as rfxtrxmod -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import EventDeviceRegistryUpdatedData @@ -30,9 +29,9 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_EVENT, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_PROTOCOLS, @@ -40,9 +39,9 @@ from .const import ( DEVICE_PACKET_TYPE_LIGHTING4, DOMAIN, EVENT_RFXTRX_EVENT, - SERVICE_SEND, SIGNAL_EVENT, ) +from .services import async_setup_services DEFAULT_OFF_DELAY = 2.0 @@ -59,18 +58,6 @@ class DeviceTuple(NamedTuple): id_string: str -def _bytearray_string(data: Any) -> bytearray: - val = cv.string(data) - try: - return bytearray.fromhex(val) - except ValueError as err: - raise vol.Invalid( - "Data must be a hex string with multiple of two characters" - ) from err - - -SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.COVER, @@ -81,6 +68,15 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up RFXtrx services.""" + hass.data.setdefault(DOMAIN, {}) + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the RFXtrx component.""" @@ -97,12 +93,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False - hass.services.async_remove(DOMAIN, SERVICE_SEND) - rfx_object = hass.data[DOMAIN][DATA_RFXOBJECT] await hass.async_add_executor_job(rfx_object.close_connection) - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(DATA_RFXOBJECT) return True @@ -284,13 +278,6 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) ) - def send(call: ServiceCall) -> None: - event = call.data[ATTR_EVENT] - rfx_object.transport.send(event) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) - async def async_setup_platform_entry( hass: HomeAssistant, diff --git a/homeassistant/components/rfxtrx/services.py b/homeassistant/components/rfxtrx/services.py new file mode 100644 index 000000000000..c1981dbc1224 --- /dev/null +++ b/homeassistant/components/rfxtrx/services.py @@ -0,0 +1,37 @@ +"""Support for RFXtrx services.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_EVENT, DATA_RFXOBJECT, DOMAIN, SERVICE_SEND + + +def _bytearray_string(data: Any) -> bytearray: + val = cv.string(data) + try: + return bytearray.fromhex(val) + except ValueError as err: + raise vol.Invalid( + "Data must be a hex string with multiple of two characters" + ) from err + + +SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the RFXtrx services.""" + + def send(call: ServiceCall) -> None: + rfx_object = hass.data.get(DOMAIN, {}).get(DATA_RFXOBJECT) + if rfx_object is None: + raise HomeAssistantError("RFXtrx is not connected, cannot send event") + rfx_object.transport.send(call.data[ATTR_EVENT]) + + hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) From 96920cc263d1ecb730f4e522e07be9615ca57e85 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Fri, 3 Jul 2026 19:12:04 +0200 Subject: [PATCH 018/707] Fix BleBox action tests to assert library calls instead of faking state (#175460) --- tests/components/blebox/test_climate.py | 137 +++---------------- tests/components/blebox/test_cover.py | 78 +++-------- tests/components/blebox/test_light.py | 169 ++---------------------- tests/components/blebox/test_sensor.py | 10 +- tests/components/blebox/test_switch.py | 60 ++------- 5 files changed, 64 insertions(+), 390 deletions(-) diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py index 6aa7c02ebcbe..7d55d8447b09 100644 --- a/tests/components/blebox/test_climate.py +++ b/tests/components/blebox/test_climate.py @@ -135,128 +135,58 @@ async def test_update(saunabox, hass: HomeAssistant, config) -> None: assert state.state == HVACMode.OFF -async def test_on_when_below_desired(saunabox, hass: HomeAssistant) -> None: - """Test when temperature is below desired.""" +async def test_set_hvac_mode_heat(saunabox, hass: HomeAssistant) -> None: + """Test that setting HVAC mode to heat calls async_on.""" feature_mock, entity_id = saunabox - feature_mock.is_on = False - await async_setup_entity(hass, entity_id) - - def turn_on(): - feature_mock.is_on = True - feature_mock.is_heating = True - feature_mock.desired = 64.8 - feature_mock.current = 25.7 - feature_mock.mode = 1 - feature_mock.async_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( - "climate", - SERVICE_SET_HVAC_MODE, - {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, - blocking=True, - ) - feature_mock.async_off.assert_not_called() - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_TEMPERATURE] == 64.8 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.7 - assert state.state == HVACMode.HEAT - - -async def test_on_when_above_desired(saunabox, hass: HomeAssistant) -> None: - """Test when temperature is below desired.""" - - feature_mock, entity_id = saunabox - - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - def turn_on(): - feature_mock.is_on = True - feature_mock.is_heating = False - feature_mock.desired = 23.4 - feature_mock.current = 28.7 - - feature_mock.mode = 1 - feature_mock.async_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( "climate", SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) + + feature_mock.async_on.assert_called_once_with() feature_mock.async_off.assert_not_called() - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_TEMPERATURE] == 23.4 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 28.7 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.state == HVACMode.HEAT -async def test_off(saunabox, hass: HomeAssistant) -> None: - """Test turning off.""" +async def test_set_hvac_mode_off(saunabox, hass: HomeAssistant) -> None: + """Test that setting HVAC mode to off calls async_off.""" feature_mock, entity_id = saunabox - feature_mock.is_on = True - feature_mock.is_heating = False await async_setup_entity(hass, entity_id) - def turn_off(): - feature_mock.is_on = False - feature_mock.is_heating = False - feature_mock.desired = 29.8 - feature_mock.current = 22.7 - - feature_mock.async_off = AsyncMock(side_effect=turn_off) await hass.services.async_call( "climate", SERVICE_SET_HVAC_MODE, {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - feature_mock.async_on.assert_not_called() - state = hass.states.get(entity_id) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF - assert state.attributes[ATTR_TEMPERATURE] == 29.8 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.7 - assert state.state == HVACMode.OFF + feature_mock.async_off.assert_called_once_with() + feature_mock.async_on.assert_not_called() async def test_set_thermo(saunabox, hass: HomeAssistant) -> None: - """Test setting thermostat.""" + """Test that setting the temperature calls async_set_temperature.""" feature_mock, entity_id = saunabox - feature_mock.is_on = False - feature_mock.is_heating = False await async_setup_entity(hass, entity_id) - def set_temp(temp): - feature_mock.is_on = True - feature_mock.is_heating = True - feature_mock.desired = 29.2 - feature_mock.current = 29.1 - - feature_mock.async_set_temperature = AsyncMock(side_effect=set_temp) await hass.services.async_call( "climate", SERVICE_SET_TEMPERATURE, {"entity_id": entity_id, ATTR_TEMPERATURE: 43.21}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_TEMPERATURE] == 29.2 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 29.1 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.state == HVACMode.HEAT + feature_mock.async_set_temperature.assert_called_once_with(43.21) async def test_update_failure( @@ -280,55 +210,30 @@ async def test_update_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_reding_hvac_actions( - saunabox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test hvac action for given device(mock) state.""" - - caplog.set_level(logging.ERROR) +async def test_hvac_action_heating(saunabox, hass: HomeAssistant) -> None: + """Test hvac_action reflects a heating device state.""" feature_mock, entity_id = saunabox + + feature_mock.is_on = True + feature_mock.hvac_action = 1 + feature_mock.mode = 1 await async_setup_entity(hass, entity_id) - def set_temperature(temp): - feature_mock.is_on = True - feature_mock.hvac_action = 1 - feature_mock.mode = 1 - - feature_mock.async_set_temperature = AsyncMock(side_effect=set_temperature) - - await hass.services.async_call( - "climate", - SERVICE_SET_TEMPERATURE, - {"entity_id": entity_id, ATTR_TEMPERATURE: 43.21}, - blocking=True, - ) state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.HEAT] -async def test_thermo_off( - thermobox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test hvac action off fir given device state.""" - caplog.set_level(logging.ERROR) +async def test_hvac_action_off(thermobox, hass: HomeAssistant) -> None: + """Test hvac_action reflects a device that is off.""" feature_mock, entity_id = thermobox + + feature_mock.is_on = False + feature_mock.hvac_action = 0 await async_setup_entity(hass, entity_id) - def set_off(): - feature_mock.is_on = False - feature_mock.hvac_action = 0 - - feature_mock.async_off = AsyncMock(side_effect=set_off) - - await hass.services.async_call( - "climate", - SERVICE_SET_HVAC_MODE, - {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, - blocking=True, - ) state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.COOL] diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index 56a3bec3e293..b057e178d56d 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -259,21 +259,16 @@ async def test_open(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 3 # manually stopped await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def open_gate(): - feature_mock.state = 1 # opening - - feature_mock.async_open = AsyncMock(side_effect=open_gate) await hass.services.async_call( "cover", SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_open.assert_called_once_with() @pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"]) @@ -282,18 +277,13 @@ async def test_close(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 4 # open await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.OPEN - def close(): - feature_mock.state = 0 # closing - - feature_mock.async_close = AsyncMock(side_effect=close) await hass.services.async_call( "cover", SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) - assert hass.states.get(entity_id).state == CoverState.CLOSING + + feature_mock.async_close.assert_called_once_with() @pytest.mark.parametrize("feature", FIXTURES_SUPPORTING_STOP, indirect=["feature"]) @@ -302,18 +292,13 @@ async def test_stop(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 1 # opening await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.OPENING - def stop(): - feature_mock.state = 2 # manually stopped - - feature_mock.async_stop = AsyncMock(side_effect=stop) await hass.services.async_call( "cover", SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True ) - assert hass.states.get(entity_id).state == CoverState.OPEN + + feature_mock.async_stop.assert_called_once_with() @pytest.mark.parametrize( @@ -355,23 +340,16 @@ async def test_set_position(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 3 # closed await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def set_position(position): - assert position == 99 # inverted - feature_mock.state = 1 # opening - # feature_mock.current = position - - feature_mock.async_set_position = AsyncMock(side_effect=set_position) await hass.services.async_call( "cover", SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, ATTR_POSITION: 1}, blocking=True, ) # almost closed - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_set_position.assert_called_once_with(99) # inverted async def test_unknown_position(shutterbox, hass: HomeAssistant) -> None: @@ -540,29 +518,23 @@ async def test_set_tilt_position(shutterbox, hass: HomeAssistant) -> None: feature_mock, entity_id = shutterbox - feature_mock.state = 3 await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def set_tilt(tilt_position): - assert tilt_position == 20 - feature_mock.state = 1 - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt) await hass.services.async_call( "cover", SERVICE_SET_COVER_TILT_POSITION, {"entity_id": entity_id, ATTR_TILT_POSITION: 80}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_set_tilt_position.assert_called_once_with(20) @pytest.mark.parametrize( - ("is_tilt_180", "expected_tilt_position", "expected_tilt_reported"), + ("is_tilt_180", "expected_tilt_position"), [ - pytest.param(False, 0, 100, id="tilt_90"), - pytest.param(True, 50, 50, id="tilt_180"), + pytest.param(False, 0, id="tilt_90"), + pytest.param(True, 50, id="tilt_180"), ], ) async def test_open_tilt( @@ -570,7 +542,6 @@ async def test_open_tilt( hass: HomeAssistant, is_tilt_180: bool, expected_tilt_position: int, - expected_tilt_reported: int, ) -> None: """Test opening tilt for 90-degree and 180-degree tilt shutters.""" feature_mock, entity_id = shutterbox @@ -578,42 +549,27 @@ async def test_open_tilt( feature_mock.tilt_current = 100 await async_setup_entity(hass, entity_id) - def set_tilt_position(tilt_position): - assert tilt_position == expected_tilt_position - feature_mock.tilt_current = tilt_position - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position) - await hass.services.async_call( "cover", SERVICE_OPEN_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert ( - state.attributes[ATTR_CURRENT_TILT_POSITION] == expected_tilt_reported - ) # inverted + + feature_mock.async_set_tilt_position.assert_called_once_with(expected_tilt_position) async def test_close_tilt(shutterbox, hass: HomeAssistant) -> None: """Test closing tilt.""" feature_mock, entity_id = shutterbox - feature_mock.tilt_current = 0 await async_setup_entity(hass, entity_id) - def set_tilt_position(tilt_position): - assert tilt_position == 100 - feature_mock.tilt_current = tilt_position - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position) - await hass.services.async_call( "cover", SERVICE_CLOSE_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # inverted + + feature_mock.async_set_tilt_position.assert_called_once_with(100) diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index 8d0bdd74291a..031f4bca861a 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock import blebox_uniapi import pytest -from homeassistant.components.blebox.const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -19,7 +18,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, STATE_ON, STATE_UNKNOWN, ) @@ -103,20 +101,9 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = False - feature_mock.brightness = 0 # off feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 254 - feature_mock.brightness = 254 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -124,9 +111,7 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 254 + feature_mock.async_on.assert_called_once_with(254) async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: @@ -134,21 +119,9 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = False - feature_mock.brightness = 0 # off feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 202 - feature_mock.brightness = 202 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - def apply(value, brightness): assert value == 254 return brightness @@ -161,9 +134,7 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_BRIGHTNESS] == 202 - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with(202) async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: @@ -171,17 +142,8 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - def turn_off(): - feature_mock.is_on = False - feature_mock.brightness = 0 # off - - feature_mock.async_off = AsyncMock(side_effect=turn_off) await hass.services.async_call( "light", SERVICE_TURN_OFF, @@ -189,9 +151,7 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - assert state.attributes[ATTR_BRIGHTNESS] is None + feature_mock.async_off.assert_called_once_with() @pytest.fixture(name="wlightbox_s") @@ -264,19 +224,9 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox_s - feature_mock.is_on = False feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 254 - feature_mock.brightness = 254 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -284,9 +234,7 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_BRIGHTNESS] == 254 - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with(254) @pytest.fixture(name="wlightbox") @@ -360,12 +308,7 @@ async def test_wlightbox_on_color_temp( transient_temp = value return [0x00, 0x39, 0xB0, 0xFF] - def turn_on(_: list[int]) -> None: - feature_mock.is_on = True - feature_mock.color_temp = transient_temp - feature_mock.return_color_temp_with_brightness = return_color_temp_with_brightness - feature_mock.async_on = AsyncMock(side_effect=turn_on) await async_setup_entity(hass, entity_id) await hass.services.async_call( @@ -375,12 +318,8 @@ async def test_wlightbox_on_color_temp( blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON assert 0 <= transient_temp <= 255 - - kelvin_actual = state.attributes[ATTR_COLOR_TEMP_KELVIN] - assert LIGHT_MIN_KELVINS <= kelvin_actual <= LIGHT_MAX_KELVINS + feature_mock.async_on.assert_called_once_with([0x00, 0x39, 0xB0, 0xFF]) async def test_wlightbox_init( @@ -431,35 +370,8 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - assert value == [193, 210, 243, 199] - feature_mock.white_value = 0xC7 # on - feature_mock.rgbw_hex = "c1d2f3c7" - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - - def apply_white(value, white): - assert value == "00010203" - assert white == 0xC7 - return "000102c7" - - feature_mock.apply_white = apply_white - - def apply_color(value, color_value): - assert value == "000102c7" - assert color_value == "c1d2f3" - return "c1d2f3c7" - - feature_mock.apply_color = apply_color - feature_mock.sensible_on_value = "00010203" - await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -467,9 +379,7 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes[ATTR_RGBW_COLOR] == (0xC1, 0xD2, 0xF3, 0xC7) + feature_mock.async_on.assert_called_once_with([193, 210, 243, 199]) async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> None: @@ -477,20 +387,8 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non feature_mock, entity_id = wlightbox - feature_mock.is_on = False - await async_setup_entity(hass, entity_id) - - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - assert value == "f1e2d3e4" - feature_mock.white_value = 0xE4 - feature_mock.rgbw_hex = value - - feature_mock.async_on = AsyncMock(side_effect=turn_on) feature_mock.sensible_on_value = "f1e2d3e4" + await async_setup_entity(hass, entity_id) await hass.services.async_call( "light", @@ -499,9 +397,7 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_RGBW_COLOR] == (0xF1, 0xE2, 0xD3, 0xE4) - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with("f1e2d3e4") async def test_wlightbox_turn_on_with_zero_brightness_turns_off( @@ -511,23 +407,8 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off( feature_mock, entity_id = wlightbox - feature_mock.is_on = True - feature_mock.rgbw_hex = "c1d2f3c7" - feature_mock.white_value = 0xC7 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - feature_mock.apply_brightness = MagicMock(return_value=[0, 0, 0, 0]) - - def turn_off(): - feature_mock.is_on = False - feature_mock.white_value = 0x0 - feature_mock.rgbw_hex = "00000000" - - feature_mock.async_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -535,31 +416,17 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off( blocking=True, ) - feature_mock.async_off.assert_called_once() + feature_mock.async_off.assert_called_once_with() feature_mock.async_on.assert_not_called() - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: """Test light off.""" feature_mock, entity_id = wlightbox - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - def turn_off(): - feature_mock.is_on = False - feature_mock.white_value = 0x0 - feature_mock.rgbw_hex = "00000000" - - feature_mock.async_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "light", SERVICE_TURN_OFF, @@ -567,9 +434,7 @@ async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_RGBW_COLOR] is None - assert state.state == STATE_OFF + feature_mock.async_off.assert_called_once_with() @pytest.mark.parametrize("feature", ALL_LIGHT_FIXTURES, indirect=["feature"]) @@ -623,18 +488,8 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - feature_mock.effect = "POLICE" - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - with pytest.raises(HomeAssistantError) as info: await hass.services.async_call( "light", @@ -644,6 +499,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: ) assert info.value.translation_key == "effect_not_found" + feature_mock.async_api_command.assert_not_called() await hass.services.async_call( "light", @@ -652,8 +508,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_EFFECT] == "POLICE" + feature_mock.async_api_command.assert_called_once_with("effect", 2) @pytest.mark.parametrize( diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 45d1d9dc1675..5a24da079bcf 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -304,10 +304,7 @@ async def test_open_status_sensor_none_value( """Test that a None native_value yields an unknown state.""" feature_mock, entity_id = open_status_sensor - def set_none(): - feature_mock.native_value = None - - feature_mock.async_update = AsyncMock(side_effect=set_none) + feature_mock.native_value = None await async_setup_entity(hass, entity_id) state = hass.states.get(entity_id) @@ -380,10 +377,7 @@ async def test_co2_definition_sensor_none_value( """Test that a None native_value yields an unknown state.""" feature_mock, entity_id = co2_definition_sensor - def set_none(): - feature_mock.native_value = None - - feature_mock.async_update = AsyncMock(side_effect=set_none) + feature_mock.native_value = None await async_setup_entity(hass, entity_id) state = hass.states.get(entity_id) diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py index 959e68b43e2f..ccd66fad439e 100644 --- a/tests/components/blebox/test_switch.py +++ b/tests/components/blebox/test_switch.py @@ -106,14 +106,8 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None: feature_mock, entity_id = switchbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - def turn_on(): - feature_mock.is_on = True - - feature_mock.async_turn_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -121,8 +115,7 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON + feature_mock.async_turn_on.assert_called_once_with() async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None: @@ -130,22 +123,16 @@ async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None: feature_mock, entity_id = switchbox - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - def turn_off(): - feature_mock.is_on = False - - feature_mock.async_turn_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "switch", SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF + + feature_mock.async_turn_off.assert_called_once_with() def relay_mock(relay_id=0): @@ -263,14 +250,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = False - feature_mocks[1].is_on = False await async_setup_entities(hass, entity_ids) - def turn_on0(): - feature_mocks[0].is_on = True - - feature_mocks[0].async_turn_on = AsyncMock(side_effect=turn_on0) await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -278,8 +259,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_ON - assert hass.states.get(entity_ids[1]).state == STATE_OFF + feature_mocks[0].async_turn_on.assert_called_once_with() + feature_mocks[1].async_turn_on.assert_not_called() async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: @@ -287,14 +268,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = False - feature_mocks[1].is_on = False await async_setup_entities(hass, entity_ids) - def turn_on1(): - feature_mocks[1].is_on = True - - feature_mocks[1].async_turn_on = AsyncMock(side_effect=turn_on1) await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -302,8 +277,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_OFF - assert hass.states.get(entity_ids[1]).state == STATE_ON + feature_mocks[0].async_turn_on.assert_not_called() + feature_mocks[1].async_turn_on.assert_called_once_with() async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: @@ -311,14 +286,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = True - feature_mocks[1].is_on = True await async_setup_entities(hass, entity_ids) - def turn_off0(): - feature_mocks[0].is_on = False - - feature_mocks[0].async_turn_off = AsyncMock(side_effect=turn_off0) await hass.services.async_call( "switch", SERVICE_TURN_OFF, @@ -326,8 +295,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_OFF - assert hass.states.get(entity_ids[1]).state == STATE_ON + feature_mocks[0].async_turn_off.assert_called_once_with() + feature_mocks[1].async_turn_off.assert_not_called() async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None: @@ -335,22 +304,17 @@ async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = True - feature_mocks[1].is_on = True await async_setup_entities(hass, entity_ids) - def turn_off1(): - feature_mocks[1].is_on = False - - feature_mocks[1].async_turn_off = AsyncMock(side_effect=turn_off1) await hass.services.async_call( "switch", SERVICE_TURN_OFF, {"entity_id": entity_ids[1]}, blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_ON - assert hass.states.get(entity_ids[1]).state == STATE_OFF + + feature_mocks[0].async_turn_off.assert_not_called() + feature_mocks[1].async_turn_off.assert_called_once_with() async def test_switchbox_with_name(hass: HomeAssistant) -> None: From 14e2d51963a02b1bdca10d874b82d0cb851cc1c2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Jul 2026 19:30:50 +0200 Subject: [PATCH 019/707] Add reconfigure flow to NextCloud (#175457) --- .../components/nextcloud/config_flow.py | 34 +++++-- .../components/nextcloud/strings.json | 19 ++-- .../nextcloud/snapshots/test_config_flow.ambr | 8 ++ .../components/nextcloud/test_config_flow.py | 90 +++++++++++++++++-- 4 files changed, 132 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index 06cf5d662a7b..8b72fc856690 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -11,7 +11,11 @@ from nextcloudmonitor import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from .const import DEFAULT_VERIFY_SSL, DOMAIN @@ -46,8 +50,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) - @override - async def async_step_user( + async def async_step_config( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" @@ -62,16 +65,37 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): except NextcloudMonitorConnectionError, NextcloudMonitorRequestError: errors["base"] = "connection_error" else: + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data_updates=user_input + ) return self.async_create_entry( title=user_input[CONF_URL], data=user_input, ) - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input) + data = user_input + if self.source == SOURCE_RECONFIGURE: + data = data or dict(self._get_reconfigure_entry().data) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, data) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="config", data_schema=data_schema, errors=errors ) + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_config(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfigure flow initialized by the user.""" + return await self.async_step_config(user_input) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 373bd86b4f42..a4997e6e78e2 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "connection_error_during_import": "Connection error occurred during yaml configuration import", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "connection_error": "[%key:common::config_flow::error::cannot_connect%]", @@ -11,14 +12,7 @@ }, "flow_title": "Nextcloud", "step": { - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "description": "Update your login information for {url}." - }, - "user": { + "config": { "data": { "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]", @@ -26,6 +20,13 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "description": "Enter your Nextcloud information." + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Update your login information for {url}." } } }, diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index e87db0a25c0d..a0b8cb4ff9cf 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -7,6 +7,14 @@ 'verify_ssl': True, }) # --- +# name: test_reconfigure_entry + dict({ + 'password': 'other_password', + 'url': 'https://my.nc_url.local', + 'username': 'other_user', + 'verify_ssl': True, + }) +# --- # name: test_user_create_entry dict({ 'password': 'nc_pass', diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 16b6bf3bc046..211d2168909b 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -32,7 +32,7 @@ async def test_user_create_entry( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {} # test NextcloudMonitorAuthorizationError @@ -46,7 +46,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "invalid_auth"} # test NextcloudMonitorConnectionError @@ -60,7 +60,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "connection_error"} # test NextcloudMonitorRequestError @@ -74,7 +74,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "connection_error"} # test success @@ -93,6 +93,86 @@ async def test_user_create_entry( assert result["data"] == snapshot +async def test_reconfigure_entry( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test that the reconfigure step works.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="https://my.nc_url.local", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {} + + # test NextcloudMonitorAuthorizationError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "connection_error"} + + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "connection_error"} + + # test success + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **VALID_CONFIG, + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == snapshot + + async def test_user_already_configured(hass: HomeAssistant) -> None: """Test that errors are shown when duplicates are added.""" entry = MockConfigEntry( @@ -107,7 +187,7 @@ async def test_user_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {} with patch( From 57f4c6eb1908f696e6f661701b621f9de2c2c1de Mon Sep 17 00:00:00 2001 From: Brian Tajuddin Date: Fri, 3 Jul 2026 10:32:13 -0700 Subject: [PATCH 020/707] Add temperature and humidity sensors to Cielo Home integration (#173043) --- .../components/cielo_home/climate.py | 24 +--- homeassistant/components/cielo_home/const.py | 4 + homeassistant/components/cielo_home/entity.py | 32 +++++ homeassistant/components/cielo_home/sensor.py | 101 ++++++++++++++++ .../cielo_home/snapshots/test_sensor.ambr | 114 ++++++++++++++++++ tests/components/cielo_home/test_sensor.py | 89 ++++++++++++++ 6 files changed, 341 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/cielo_home/sensor.py create mode 100644 tests/components/cielo_home/snapshots/test_sensor.ambr create mode 100644 tests/components/cielo_home/test_sensor.py diff --git a/homeassistant/components/cielo_home/climate.py b/homeassistant/components/cielo_home/climate.py index 38ddd9cbf2df..37d315c108ef 100644 --- a/homeassistant/components/cielo_home/climate.py +++ b/homeassistant/components/cielo_home/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -103,28 +103,6 @@ class CieloClimate(CieloDeviceEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_unique_id = device_id - @property - @override - def temperature_unit(self) -> str: - """Return the unit of temperature in Home Assistant format. - - It can change over time based on the device settings, - so we fetch it dynamically from the client. - """ - unit = self.client.temperature_unit() - - if not unit: - return UnitOfTemperature.CELSIUS - - normalized = unit.strip().lower() - - if normalized in {"c", "°c", "celsius"}: - return UnitOfTemperature.CELSIUS - if normalized in {"f", "°f", "fahrenheit"}: - return UnitOfTemperature.FAHRENHEIT - - return UnitOfTemperature.CELSIUS - @property @override def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/cielo_home/const.py b/homeassistant/components/cielo_home/const.py index dbc3d68d342a..1818f472b5c0 100644 --- a/homeassistant/components/cielo_home/const.py +++ b/homeassistant/components/cielo_home/const.py @@ -11,12 +11,16 @@ from homeassistant.const import Platform DOMAIN: Final = "cielo_home" PLATFORMS: Final[list[Platform]] = [ Platform.CLIMATE, + Platform.SENSOR, ] DEFAULT_NAME: Final = "Cielo Home" DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60 TIMEOUT: Final[int] = 20 LOGGER: Final = logging.getLogger(__package__) +SENSOR_TEMPERATURE: Final = "temperature" +SENSOR_HUMIDITY: Final = "humidity" + CIELO_ERRORS: Final[tuple] = ( ClientError, TimeoutError, diff --git a/homeassistant/components/cielo_home/entity.py b/homeassistant/components/cielo_home/entity.py index dfe2e9760440..fdceea79f40f 100644 --- a/homeassistant/components/cielo_home/entity.py +++ b/homeassistant/components/cielo_home/entity.py @@ -5,6 +5,7 @@ from typing import override from cieloconnectapi.device import CieloDeviceAPI from cieloconnectapi.model import CieloDevice +from homeassistant.const import UnitOfTemperature from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -12,6 +13,26 @@ from .const import DOMAIN from .coordinator import CieloDataUpdateCoordinator +def normalize_temp_unit(client: CieloDeviceAPI) -> str: + """Normalize a raw device temperature unit to a UnitOfTemperature value. + + Unrecognized or empty values fall back to Celsius. + """ + unit = client.temperature_unit() + + if not unit: + return UnitOfTemperature.CELSIUS + + normalized = unit.strip().lower() + + if normalized in {"c", "°c", "celsius"}: + return UnitOfTemperature.CELSIUS + if normalized in {"f", "°f", "fahrenheit"}: + return UnitOfTemperature.FAHRENHEIT + + return UnitOfTemperature.CELSIUS + + class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]): """Representation of a Cielo base entity.""" @@ -74,3 +95,14 @@ class CieloDeviceEntity(CieloBaseEntity): configuration_url="https://home.cielowigle.com/", suggested_area=device.name, ) + + @property + def temperature_unit(self) -> str: + """Return the unit of temperature for the device. + + The unit can change over time based on the device settings, + so it is fetched dynamically from the client. This dynamic + nature means that if a user changes the device's temperature + unit, historical statistics may be affected. + """ + return normalize_temp_unit(self.client) diff --git a/homeassistant/components/cielo_home/sensor.py b/homeassistant/components/cielo_home/sensor.py new file mode 100644 index 000000000000..b1cfe14e24d9 --- /dev/null +++ b/homeassistant/components/cielo_home/sensor.py @@ -0,0 +1,101 @@ +"""Support for Cielo Home sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from cieloconnectapi.device import CieloDeviceAPI +from cieloconnectapi.model import CieloDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SENSOR_HUMIDITY, SENSOR_TEMPERATURE +from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry +from .entity import CieloDeviceEntity, normalize_temp_unit + + +@dataclass(kw_only=True, frozen=True) +class CieloSensorEntityDescription(SensorEntityDescription): + """Describes a Cielo Home sensor entity.""" + + value_fn: Callable[[CieloDeviceAPI, CieloDevice | None], float | int | None] + unit_fn: Callable[[CieloDeviceAPI], str | None] | None = None + + +SENSOR_DESCRIPTIONS: tuple[CieloSensorEntityDescription, ...] = ( + CieloSensorEntityDescription( + key=SENSOR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client, device_data: client.current_temperature(), + # Temperature unit is dynamic; see the native_unit_of_measurement property for limitations. + unit_fn=normalize_temp_unit, + ), + CieloSensorEntityDescription( + key=SENSOR_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda client, device_data: ( + device_data.humidity if device_data else None + ), + ), +) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CieloHomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Cielo Home sensors.""" + coordinator = entry.runtime_data + + entities = [ + CieloSensor(coordinator, device_id, description) + for device_id in coordinator.data.parsed + for description in SENSOR_DESCRIPTIONS + ] + async_add_entities(entities) + + +class CieloSensor(CieloDeviceEntity, SensorEntity): + """Representation of a Cielo Home sensor.""" + + entity_description: CieloSensorEntityDescription + + def __init__( + self, + coordinator: CieloDataUpdateCoordinator, + device_id: str, + entity_description: CieloSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id) + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}-{entity_description.key}" + + @property + @override + def native_value(self) -> float | int | None: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self.client, self.device_data) + + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + if self.entity_description.unit_fn is not None: + return self.entity_description.unit_fn(self.client) + return super().native_unit_of_measurement diff --git a/tests/components/cielo_home/snapshots/test_sensor.ambr b/tests/components/cielo_home/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..fc17c32ff764 --- /dev/null +++ b/tests/components/cielo_home/snapshots/test_sensor.ambr @@ -0,0 +1,114 @@ +# serializer version: 1 +# name: test_all_entities[sensor.living_room_living_room_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_living_room_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'cielo_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'device_1-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'humidity', + : 'Living Room Humidity', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'cielo_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'device_1-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- diff --git a/tests/components/cielo_home/test_sensor.py b/tests/components/cielo_home/test_sensor.py new file mode 100644 index 000000000000..aff732ae9154 --- /dev/null +++ b/tests/components/cielo_home/test_sensor.py @@ -0,0 +1,89 @@ +"""Tests for the Cielo Home sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_cielo_client", "mock_cielo_device_api") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all sensor entities.""" + with patch("homeassistant.components.cielo_home.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_cielo_client") +@pytest.mark.parametrize( + ("temperature_unit", "hass_units", "expected_unit"), + [ + pytest.param( + "°F", + US_CUSTOMARY_SYSTEM, + UnitOfTemperature.FAHRENHEIT, + id="fahrenheit", + ), + pytest.param( + "unknown", + METRIC_SYSTEM, + UnitOfTemperature.CELSIUS, + id="unknown_unit", + ), + pytest.param( + None, + METRIC_SYSTEM, + UnitOfTemperature.CELSIUS, + id="none_unit", + ), + ], +) +async def test_temperature_sensor_unit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cielo_device_api: MagicMock, + entity_registry: er.EntityRegistry, + temperature_unit: str | None, + hass_units: UnitSystem, + expected_unit: str, +) -> None: + """Test temperature sensor reports the correct unit.""" + mock_cielo_device_api.temperature_unit.return_value = temperature_unit + hass.config.units = hass_units + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.living_room_living_room_temperature") + assert state is not None + assert state.attributes.get("unit_of_measurement") == expected_unit + + entry = entity_registry.async_get("sensor.living_room_living_room_temperature") + assert entry is not None + assert entry.unit_of_measurement == expected_unit From 3eadab8cf002351508c203bc3dd08e4b8a2d2b3b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 3 Jul 2026 10:58:11 -0700 Subject: [PATCH 021/707] Handle late MCP OAuth authentication failures (#175381) --- homeassistant/components/mcp/auth.py | 36 ++ homeassistant/components/mcp/config_flow.py | 39 +-- homeassistant/components/mcp/coordinator.py | 44 ++- tests/components/mcp/test_config_flow.py | 133 ++++++++ tests/components/mcp/test_init.py | 348 +++++++++++++++++++- 5 files changed, 567 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/mcp/auth.py diff --git a/homeassistant/components/mcp/auth.py b/homeassistant/components/mcp/auth.py new file mode 100644 index 000000000000..6b0ee76e1990 --- /dev/null +++ b/homeassistant/components/mcp/auth.py @@ -0,0 +1,36 @@ +"""Authentication helper classes for the Model Context Protocol integration.""" + +from dataclasses import dataclass +import re + +import httpx +from yarl import URL + +# Headers and regex for WWW-Authenticate parsing for rfc9728 +WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" +RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"' +SCOPES_REGEXP = r'scope="([^"]+)"' + + +@dataclass +class AuthenticateHeader: + """Class to hold info from the WWW-Authenticate header for supporting rfc9728.""" + + resource_metadata_url: str + scopes: list[str] | None = None + + @classmethod + def from_header( + cls, url: str, error_response: httpx.Response + ) -> AuthenticateHeader | None: + """Create AuthenticateHeader from WWW-Authenticate header.""" + if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not ( + match := re.search(RESOURCE_METADATA_REGEXP, header) + ): + return None + resource_metadata_url = str(URL(url).join(URL(match.group(1)))) + scope_match = re.search(SCOPES_REGEXP, header) + return cls( + resource_metadata_url=resource_metadata_url, + scopes=scope_match.group(1).split(" ") if scope_match else None, + ) diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 1e77143359dc..982cc0a4884f 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Iterable, Mapping from dataclasses import dataclass import logging -import re from typing import Any, cast, override import httpx @@ -24,6 +23,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context +from .auth import AuthenticateHeader from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN from .coordinator import TokenManager, mcp_client @@ -35,35 +35,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) -# Headers and regex for WWW-Authenticate parsing for rfc9728 -WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" -RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"' OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource" -SCOPES_REGEXP = r'scope="([^"]+)"' - - -@dataclass -class AuthenticateHeader: - """Class to hold info from the WWW-Authenticate header for supporting rfc9728.""" - - resource_metadata_url: str - scopes: list[str] | None = None - - @classmethod - def from_header( - cls, url: str, error_response: httpx.Response - ) -> AuthenticateHeader | None: - """Create AuthenticateHeader from WWW-Authenticate header.""" - if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not ( - match := re.search(RESOURCE_METADATA_REGEXP, header) - ): - return None - resource_metadata_url = str(URL(url).join(URL(match.group(1)))) - scope_match = re.search(SCOPES_REGEXP, header) - return cls( - resource_metadata_url=resource_metadata_url, - scopes=scope_match.group(1).split(" ") if scope_match else None, - ) @dataclass @@ -369,6 +341,8 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" + if entry_data and "auth_header" in entry_data: + self.auth_header = entry_data["auth_header"] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -379,6 +353,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm") config_entry = self._get_reauth_entry() self.data = {**config_entry.data} + if "auth_implementation" not in self.data: + # For entries configured without authentication (no-auth), any authentication + # failure (from a tool call or coordinator update) requires upgrading to OAuth. + # We bypass validate_input connection handshake (which might succeed if the server + # doesn't restrict the connection handshake itself) and proceed directly to OAuth discovery. + return await self.async_step_auth_discovery() + self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment] self.hass, config_entry ) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index e9bffdc0c8f5..4257449c94da 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -18,12 +18,17 @@ from voluptuous_openapi import convert_to_voluptuous from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType +from .auth import AuthenticateHeader from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -98,6 +103,7 @@ class ModelContextProtocolTool(llm.Tool): description: str | None, parameters: vol.Schema, server_url: str, + config_entry: ConfigEntry, token_manager: TokenManager | None = None, ) -> None: """Initialize the tool.""" @@ -105,6 +111,7 @@ class ModelContextProtocolTool(llm.Tool): self.description = description self.parameters = parameters self.server_url = server_url + self.config_entry = config_entry self.token_manager = token_manager @override @@ -126,9 +133,32 @@ class ModelContextProtocolTool(llm.Tool): except TimeoutError as error: _LOGGER.debug("Timeout when calling tool: %s", error) raise HomeAssistantError(f"Timeout when calling tool: {error}") from error + except OAuth2TokenRequestReauthError as error: + _LOGGER.debug("OAuth token request failed when calling tool: %s", error) + self.config_entry.async_start_reauth(hass) + raise ConfigEntryAuthFailed( + "OAuth token request failed when calling tool" + ) from error except httpx.HTTPStatusError as error: _LOGGER.debug("Error when calling tool: %s", error) + if error.response.status_code == 401: + auth_header = AuthenticateHeader.from_header( + self.server_url, error.response + ) + self.config_entry.async_start_reauth( + hass, data={"auth_header": auth_header} + ) + raise ConfigEntryAuthFailed( + "The MCP server requires authentication" + ) from error raise HomeAssistantError(f"Error when calling tool: {error}") from error + except httpx.HTTPError as error: + _LOGGER.debug( + "Error communicating with MCP server when calling tool: %s", error + ) + raise HomeAssistantError( + f"Error communicating with MCP server when calling tool: {error}" + ) from error return result.model_dump(exclude_unset=True, exclude_none=True) @@ -169,9 +199,18 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): except TimeoutError as error: _LOGGER.debug("Timeout when listing tools: %s", error) raise UpdateFailed(f"Timeout when listing tools: {error}") from error + except OAuth2TokenRequestReauthError as error: + _LOGGER.debug("OAuth token request failed: %s", error) + raise ConfigEntryAuthFailed("OAuth token request failed") from error except httpx.HTTPStatusError as error: _LOGGER.debug("Error communicating with API: %s", error) - if error.response.status_code == 401 and self.token_manager is not None: + if error.response.status_code == 401: + auth_header = AuthenticateHeader.from_header( + self.config_entry.data[CONF_URL], error.response + ) + self.config_entry.async_start_reauth( + self.hass, data={"auth_header": auth_header} + ) raise ConfigEntryAuthFailed( "The MCP server requires authentication" ) from error @@ -195,6 +234,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.description, parameters, self.config_entry.data[CONF_URL], + self.config_entry, self.token_manager, ) ) diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 3dc43daaaf70..e25e0d82c9b2 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -9,6 +9,7 @@ import pytest import respx from homeassistant import config_entries +from homeassistant.components.mcp.auth import AuthenticateHeader from homeassistant.components.mcp.const import ( CONF_AUTHORIZATION_URL, CONF_SCOPE, @@ -892,3 +893,135 @@ async def test_reauth_flow( assert token == OAUTH_TOKEN_PAYLOAD assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow_upgrade_to_oauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test reauth flow upgrading a no-auth entry to OAuth.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + auth_header = AuthenticateHeader( + resource_metadata_url="https://example.com/custom-discovery", + scopes=SCOPES_SUPPORTED, + ) + + # Start reauth flow passing auth_header + config_entry.async_start_reauth(hass, data={"auth_header": auth_header}) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # Mock discovery URLs (bypassing connection validation) + respx.get("https://example.com/custom-discovery").mock( + return_value=OAUTH_PROTECTED_RESOURCE_METADATA_RESPONSE + ) + respx.get(OAUTH_AUTHORIZATION_SERVER_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + # Click Submit on reauth_confirm + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Flow should proceed to credentials choice + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + authorize_url=OAUTH_AUTHORIZE_URL, + token_url=OAUTH_TOKEN_URL, + scopes=SCOPES_SUPPORTED, + ) + + # Verify we can connect to the server now with the token + response = Mock() + response.serverInfo.name = TEST_API_NAME + # Return success for validation in async_oauth_create_entry + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.unique_id is None + assert config_entry.title == TEST_API_NAME + data = {**config_entry.data} + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_SCOPE: SCOPES_SUPPORTED, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow_upgrade_to_oauth_no_auth_header( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test reauth flow upgrading a no-auth entry to OAuth when no auth header is passed (fallback).""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + # Start reauth flow without passing auth_header + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # Mock discovery on the default server URL (since there is no auth_header) + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + # Click Submit on reauth_confirm + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Flow should proceed directly to credentials choice menu (without validate_input) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 0a1f67e13363..70bb2aa22d6c 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -9,9 +9,15 @@ from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, T import pytest import voluptuous as vol +from homeassistant.components.mcp.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import llm from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -84,7 +90,6 @@ async def test_init( [ (httpx.TimeoutException("Some timeout")), (httpx.HTTPStatusError("", request=None, response=httpx.Response(500))), - (httpx.HTTPStatusError("", request=None, response=httpx.Response(401))), (httpx.HTTPError("Some HTTP error")), ], ) @@ -104,6 +109,55 @@ async def test_mcp_server_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_mcp_server_setup_auth_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test setup auth failure triggers reauth.""" + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_mcp_server_setup_auth_failure_with_www_authenticate_header( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test setup auth failure with WWW-Authenticate header parses header and triggers reauth.""" + headers = { + "WWW-Authenticate": 'mcp resource_metadata="https://example.com/custom-discovery", scope="read write"' + } + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", + request=None, + response=httpx.Response(401, headers=headers), + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Get the flow handler instance and verify it has the correct auth_header + flow_handler = hass.config_entries.flow._progress[flows[0]["flow_id"]] + assert flow_handler.auth_header is not None + assert ( + flow_handler.auth_header.resource_metadata_url + == "https://example.com/custom-discovery" + ) + + async def test_mcp_server_http_transport_failure( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -361,3 +415,293 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY + + +async def test_tool_call_no_auth_auth_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call auth failure when no auth was initially required.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call encountering a 401 response + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + + with pytest.raises(ConfigEntryAuthFailed): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_tool_call_no_auth_auth_failure_with_www_authenticate_header( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call 401 with WWW-Authenticate header triggers reauth and passes header.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call encountering a 401 response with WWW-Authenticate header + headers = { + "WWW-Authenticate": 'mcp resource_metadata="https://example.com/custom-discovery", scope="read write"' + } + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Authentication required", + request=None, + response=httpx.Response(401, headers=headers), + ) + + with pytest.raises(ConfigEntryAuthFailed): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Get the flow handler instance and verify it has the correct auth_header + flow_handler = hass.config_entries.flow._progress[flows[0]["flow_id"]] + assert flow_handler.auth_header is not None + assert ( + flow_handler.auth_header.resource_metadata_url + == "https://example.com/custom-discovery" + ) + + +async def test_tool_call_expired_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call token refresh failure when OAuth is configured.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock token validation failure during tool call + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ), + pytest.raises(ConfigEntryAuthFailed), + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_mcp_server_setup_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, +) -> None: + """Test setup OAuth failure triggers reauth.""" + # Mock token validation failure (e.g. refresh token expired) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_list_tools_timeout( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test setup fails with SETUP_RETRY if list tools times out.""" + mock_mcp_client.return_value.list_tools.side_effect = TimeoutError( + "Listing tools timed out" + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_tool_call_timeout( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call timing out raises HomeAssistantError.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call timeout + mock_mcp_client.return_value.call_tool.side_effect = TimeoutError("Call timed out") + + with pytest.raises(HomeAssistantError, match="Timeout when calling tool"): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + +async def test_tool_call_transient_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call transient token refresh failure does not trigger reauth.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock transient token validation failure (e.g. 503 Service Unavailable) + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ), + pytest.raises(HomeAssistantError), + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + # Verify no reauth flow is initiated + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +async def test_mcp_server_setup_transient_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, +) -> None: + """Test setup transient OAuth failure does not trigger reauth.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +async def test_tool_call_http_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call HTTP error raises HomeAssistantError.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call raising HTTPError + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPError( + "Connection timed out or failed" + ) + + with pytest.raises( + HomeAssistantError, + match="Error communicating with MCP server when calling tool", + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) From 8ca90154af9c3adbd43da617e89767bd2ad96f9b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:02:00 +0200 Subject: [PATCH 022/707] Add light LLM tools platform (#175519) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/light/llm.py | 33 +++++++++++++++++ tests/components/light/test_llm.py | 51 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 homeassistant/components/light/llm.py create mode 100644 tests/components/light/test_llm.py diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py new file mode 100644 index 000000000000..c9387aa82d71 --- /dev/null +++ b/homeassistant/components/light/llm.py @@ -0,0 +1,33 @@ +"""LLM tools for the light integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_SET + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_SET,) + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return LLMTools(tools=[]) + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/light/test_llm.py b/tests/components/light/test_llm.py new file mode 100644 index 000000000000..df57f50e2b77 --- /dev/null +++ b/tests/components/light/test_llm.py @@ -0,0 +1,51 @@ +"""Tests for the light LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "light.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a light entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "light", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test light"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the light platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed light entity.""" + assert "HassLightSet" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no light entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassLightSet" not in await _tool_names(hass) From e9424fa0a0ed04ec7103d5dc95e676fd70e07c43 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:04:04 +0200 Subject: [PATCH 023/707] Add vacuum LLM tools platform (#175525) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/vacuum/llm.py | 41 ++++++++++++++++++++ tests/components/vacuum/test_llm.py | 52 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 homeassistant/components/vacuum/llm.py create mode 100644 tests/components/vacuum/test_llm.py diff --git a/homeassistant/components/vacuum/llm.py b/homeassistant/components/vacuum/llm.py new file mode 100644 index 000000000000..da37732137f5 --- /dev/null +++ b/homeassistant/components/vacuum/llm.py @@ -0,0 +1,41 @@ +"""LLM tools for the vacuum integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return LLMTools(tools=[]) + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/vacuum/test_llm.py b/tests/components/vacuum/test_llm.py new file mode 100644 index 000000000000..ec3cc8e7c8a9 --- /dev/null +++ b/tests/components/vacuum/test_llm.py @@ -0,0 +1,52 @@ +"""Tests for the vacuum LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "vacuum.test" +INTENTS = {"HassVacuumCleanArea", "HassVacuumReturnToBase", "HassVacuumStart"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a vacuum entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "vacuum", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test vacuum"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the vacuum platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed vacuum entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no vacuum entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) From 949ada2e72a82b2371f7cb5556dfc5027512801f Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 3 Jul 2026 20:04:22 +0200 Subject: [PATCH 024/707] Homee: fix unavailable entities - 2nd FollowUp (#175382) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/components/homee/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py index 66c4effe49ae..b601b4de294e 100644 --- a/tests/components/homee/test_init.py +++ b/tests/components/homee/test_init.py @@ -147,9 +147,9 @@ async def test_attribute_availability( await hass.async_block_till_done() assert ( - hass.states.get("siren.test_siren").state is not STATE_UNAVAILABLE + hass.states.get("siren.test_siren").state != STATE_UNAVAILABLE if state < AttributeState.INACTIVE - else STATE_UNAVAILABLE + else hass.states.get("siren.test_siren").state == STATE_UNAVAILABLE ) From f562bda70ac0f522f0c4b3beb0d989cb48b673fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:06:10 +0200 Subject: [PATCH 025/707] Add intent_script LLM tools platform (#175518) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/intent_script/llm.py | 44 +++++++++++ tests/components/intent_script/test_llm.py | 73 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 homeassistant/components/intent_script/llm.py create mode 100644 tests/components/intent_script/test_llm.py diff --git a/homeassistant/components/intent_script/llm.py b/homeassistant/components/intent_script/llm.py new file mode 100644 index 000000000000..81e9e6d91cd1 --- /dev/null +++ b/homeassistant/components/intent_script/llm.py @@ -0,0 +1,44 @@ +"""LLM tools for the intent_script integration.""" + +import slugify as unicode_slug + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool + +from . import ScriptIntentHandler + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return an LLM tool for each configured intent script.""" + handlers = [ + handler + for handler in intent.async_get(hass) + if isinstance(handler, ScriptIntentHandler) + ] + + if llm_context.assistant is not None: + exposed_domains = { + state.domain + for state in hass.states.async_all() + if async_should_expose(hass, llm_context.assistant, state.entity_id) + } + handlers = [ + handler + for handler in handlers + if handler.platforms is None or handler.platforms & exposed_domains + ] + + # Intent script names come from user configuration, so slugify them into + # valid tool names. + tools: list[Tool] = [ + IntentTool( + unicode_slug.slugify(handler.intent_type, separator="_", lowercase=False), + handler, + ) + for handler in handlers + ] + return LLMTools(tools=tools) diff --git a/tests/components/intent_script/test_llm.py b/tests/components/intent_script/test_llm.py new file mode 100644 index 000000000000..a20803810508 --- /dev/null +++ b/tests/components/intent_script/test_llm.py @@ -0,0 +1,73 @@ +"""Tests for the intent_script LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +LIGHT_ENTITY_ID = "light.kitchen" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and configure intent scripts.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "Tell a joke": { + "description": "Tell a joke", + "speech": {"text": "Why did the chicken cross the road?"}, + }, + "LightAction": { + "description": "Do a light thing", + "platforms": ["light"], + "speech": {"text": "Done"}, + }, + } + }, + ) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(LIGHT_ENTITY_ID, "on", {"friendly_name": "Kitchen Light"}) + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the intent_script platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_scripts_exposed(hass: HomeAssistant) -> None: + """Test intent scripts are exposed as LLM tools with slugified names.""" + names = await _tool_names(hass) + # The user-provided "Tell a joke" name is slugified into a valid tool name. + assert "Tell_a_joke" in names + assert "LightAction" in names + + +async def test_intent_script_platform_filtered(hass: HomeAssistant) -> None: + """Test a platform-restricted intent script requires an exposed entity.""" + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, False) + names = await _tool_names(hass) + assert "LightAction" not in names + # Unrestricted intent scripts stay exposed. + assert "Tell_a_joke" in names From 5a8aa546ca3f6fd1cec81ce6933e8648c9be2125 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:07:01 +0200 Subject: [PATCH 026/707] Add climate LLM tools platform (#175520) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/climate/llm.py | 32 ++++++++++++++++ tests/components/climate/test_llm.py | 51 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 homeassistant/components/climate/llm.py create mode 100644 tests/components/climate/test_llm.py diff --git a/homeassistant/components/climate/llm.py b/homeassistant/components/climate/llm.py new file mode 100644 index 000000000000..2a8485494285 --- /dev/null +++ b/homeassistant/components/climate/llm.py @@ -0,0 +1,32 @@ +"""LLM tools for the climate integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool + +from .const import DOMAIN, INTENT_SET_TEMPERATURE + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_SET_TEMPERATURE,) + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return LLMTools(tools=[]) + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py new file mode 100644 index 000000000000..d702880b9c1f --- /dev/null +++ b/tests/components/climate/test_llm.py @@ -0,0 +1,51 @@ +"""Tests for the climate LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "climate.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a climate entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "climate", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test climate"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the climate platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed climate entity.""" + assert "HassClimateSetTemperature" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no climate entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassClimateSetTemperature" not in await _tool_names(hass) From c13ca88ef08b34363b9ab0ff7d1afb30224a7c9a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:07:37 +0200 Subject: [PATCH 027/707] Add fan LLM tools platform (#175521) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/fan/llm.py | 33 +++++++++++++++++++ tests/components/fan/test_llm.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 homeassistant/components/fan/llm.py create mode 100644 tests/components/fan/test_llm.py diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py new file mode 100644 index 000000000000..1dedbdbf0f67 --- /dev/null +++ b/homeassistant/components/fan/llm.py @@ -0,0 +1,33 @@ +"""LLM tools for the fan integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool + +from . import DOMAIN +from .intent import INTENT_FAN_SET_SPEED + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_FAN_SET_SPEED,) + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return LLMTools(tools=[]) + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/fan/test_llm.py b/tests/components/fan/test_llm.py new file mode 100644 index 000000000000..9caacb703f80 --- /dev/null +++ b/tests/components/fan/test_llm.py @@ -0,0 +1,51 @@ +"""Tests for the fan LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "fan.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a fan entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "fan", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test fan"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the fan platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed fan entity.""" + assert "HassFanSetSpeed" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no fan entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassFanSetSpeed" not in await _tool_names(hass) From c00f4e894a10d5165486283e8302108da1b7bc2c Mon Sep 17 00:00:00 2001 From: smartcircuits <166529976+smartcircuits@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:09:25 +0200 Subject: [PATCH 028/707] =?UTF-8?q?Add=20diagnostics=20to=20WattW=C3=A4cht?= =?UTF-8?q?er=20Plus=20(#175384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/wattwaechter/diagnostics.py | 53 +++++++++++++ .../wattwaechter/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 75 +++++++++++++++++++ .../wattwaechter/test_diagnostics.py | 48 ++++++++++++ tests/components/wattwaechter/test_sensor.py | 33 +++++++- 5 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/wattwaechter/diagnostics.py create mode 100644 tests/components/wattwaechter/snapshots/test_diagnostics.ambr create mode 100644 tests/components/wattwaechter/test_diagnostics.py diff --git a/homeassistant/components/wattwaechter/diagnostics.py b/homeassistant/components/wattwaechter/diagnostics.py new file mode 100644 index 000000000000..a59cfaa9df29 --- /dev/null +++ b/homeassistant/components/wattwaechter/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for the WattWächter Plus integration.""" + +from dataclasses import asdict +from typing import Any + +from aio_wattwaechter import ( + WattwaechterAuthenticationError, + WattwaechterConnectionError, +) +from aio_wattwaechter.models import SystemInfo + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_MAC, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import WattwaechterConfigEntry + +# The device exposes network identifiers as system info values; redact the +# credential and hardware/network identifiers. Local IPs are kept for support. +TO_REDACT = {CONF_TOKEN, CONF_MAC, "ssid", "mac_address", "mdns_name"} + + +def _flatten_system(system: SystemInfo) -> dict[str, dict[str, Any]]: + """Flatten system info sections into {section: {name: value}} mappings.""" + return { + section: {entry["name"]: entry["value"] for entry in entries} + for section, entries in asdict(system).items() + } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: WattwaechterConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + # System info is only needed on demand here, so it is fetched directly + # instead of in the update loop to avoid coupling meter sensor + # availability to it. Failure still yields the config and meter data. + system: dict[str, dict[str, Any]] | None = None + try: + system = _flatten_system(await coordinator.client.system_info()) + except WattwaechterConnectionError, WattwaechterAuthenticationError: + system = None + + return async_redact_data( + { + "config_entry": dict(entry.data), + "meter": asdict(coordinator.data), + "system": system, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/wattwaechter/quality_scale.yaml b/homeassistant/components/wattwaechter/quality_scale.yaml index 247726f5ade8..a831a42304cf 100644 --- a/homeassistant/components/wattwaechter/quality_scale.yaml +++ b/homeassistant/components/wattwaechter/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo diff --git a/tests/components/wattwaechter/snapshots/test_diagnostics.ambr b/tests/components/wattwaechter/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..c293927a0baf --- /dev/null +++ b/tests/components/wattwaechter/snapshots/test_diagnostics.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'device_id': 'ABC123', + 'fw_version': '1.2.3', + 'host': '192.168.1.100', + 'mac': '**REDACTED**', + 'model': 'WW-Plus', + 'token': '**REDACTED**', + }), + 'meter': dict({ + 'datetime_str': '2024-01-01T00:00:00', + 'timestamp': 1704067200, + 'values': dict({ + '1.8.0': dict({ + 'name': 'Total Import', + 'unit': 'kWh', + 'value': 12345.678, + }), + '13.7.0': dict({ + 'name': 'Power Factor', + 'unit': '', + 'value': 0.985, + }), + '14.7.0': dict({ + 'name': 'Frequency', + 'unit': 'Hz', + 'value': 50.01, + }), + '16.7.0': dict({ + 'name': 'Active Power', + 'unit': 'W', + 'value': 1500.5, + }), + '2.8.0': dict({ + 'name': 'Total Export', + 'unit': 'kWh', + 'value': 1234.567, + }), + '31.7.0': dict({ + 'name': 'Current L1', + 'unit': 'A', + 'value': 6.52, + }), + '32.7.0': dict({ + 'name': 'Voltage L1', + 'unit': 'V', + 'value': 230.1, + }), + }), + }), + 'system': dict({ + 'ap': dict({ + }), + 'esp': dict({ + 'esp_id': 'ABC123', + 'os_version': '1.2.3', + }), + 'heap': dict({ + 'free_heap': '120000', + }), + 'uptime': dict({ + 'uptime': '2d 5h 30m', + }), + 'wifi': dict({ + 'ip_address': '192.168.1.100', + 'mac_address': '**REDACTED**', + 'mdns_name': '**REDACTED**', + 'signal_strength': '-45', + 'ssid': '**REDACTED**', + }), + }), + }) +# --- diff --git a/tests/components/wattwaechter/test_diagnostics.py b/tests/components/wattwaechter/test_diagnostics.py new file mode 100644 index 000000000000..6c67343b6a8d --- /dev/null +++ b/tests/components/wattwaechter/test_diagnostics.py @@ -0,0 +1,48 @@ +"""Tests for the WattWächter Plus diagnostics.""" + +from unittest.mock import AsyncMock + +from aio_wattwaechter import WattwaechterConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_diagnostics_system_info_unavailable( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, +) -> None: + """Test diagnostics still return config and meter data without system info.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_client.system_info.side_effect = WattwaechterConnectionError("offline") + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result["system"] is None + assert result["meter"] is not None diff --git a/tests/components/wattwaechter/test_sensor.py b/tests/components/wattwaechter/test_sensor.py index 60103cd52748..d20615393038 100644 --- a/tests/components/wattwaechter/test_sensor.py +++ b/tests/components/wattwaechter/test_sensor.py @@ -2,17 +2,20 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from homeassistant.components.wattwaechter.const import DOMAIN +from homeassistant.components.wattwaechter.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .conftest import MOCK_DEVICE_ID, MOCK_METER_DATA_MINIMAL -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_entities( @@ -54,3 +57,29 @@ async def test_minimal_meter_data( assert _get_entity_id("2.8.0") is None assert _get_entity_id("32.7.0") is None assert _get_entity_id("31.7.0") is None + + +async def test_sensor_value_unknown_when_obis_stops_reporting( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a sensor reports unknown when its OBIS code is no longer reported.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{MOCK_DEVICE_ID}_2.8.0" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + # Device stops reporting the export total OBIS code + mock_client.meter_data.return_value = MOCK_METER_DATA_MINIMAL + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN From b2863db4709e7ced7785ee1c187b77db0a927cfd Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:15:24 +0200 Subject: [PATCH 029/707] Rename component to integration in IQVIA (#175547) --- homeassistant/components/iqvia/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 628781f6bd88..e6ea6160ffdf 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the IQVIA component.""" +"""Config flow to configure the IQVIA integration.""" from typing import Any, override From 86ce7adf4abc8d39ab3df7a95ba82e907a78bd87 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:15:40 +0200 Subject: [PATCH 030/707] Rename component to integration in HaveIBeenPwned (#175546) --- homeassistant/components/haveibeenpwned/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/haveibeenpwned/__init__.py b/homeassistant/components/haveibeenpwned/__init__.py index adead4ec46e0..69b93fc33ebf 100644 --- a/homeassistant/components/haveibeenpwned/__init__.py +++ b/homeassistant/components/haveibeenpwned/__init__.py @@ -1 +1 @@ -"""The haveibeenpwned component.""" +"""The HaveIBeenPwned integration.""" From f720401369ff5d45b87e065483389edd72877fb1 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:21:36 +0200 Subject: [PATCH 031/707] Rename component to integration in Aurora (#175545) --- homeassistant/components/aurora/__init__.py | 2 +- homeassistant/components/aurora/coordinator.py | 2 +- homeassistant/components/aurora/entity.py | 2 +- tests/components/aurora/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index a48d704141fc..70b66bf2b82c 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index b6fb8df0f7ea..1b485f551f3b 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from datetime import timedelta import logging diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 317b82aed5a0..4403bdecd3d7 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/tests/components/aurora/__init__.py b/tests/components/aurora/__init__.py index eca5281f6312..5be18fffb385 100644 --- a/tests/components/aurora/__init__.py +++ b/tests/components/aurora/__init__.py @@ -6,7 +6,7 @@ from tests.common import MockConfigEntry async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Fixture for setting up the component.""" + """Fixture for setting up the integration.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) From 3197e690c87a10bb175dca039ffbd18917f23aa4 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:22:12 +0200 Subject: [PATCH 032/707] Rename component to integration in HomematicIP Cloud (#175543) --- homeassistant/components/homematicip_cloud/__init__.py | 2 +- homeassistant/components/homematicip_cloud/config_flow.py | 4 ++-- homeassistant/components/homematicip_cloud/const.py | 2 +- homeassistant/components/homematicip_cloud/entity.py | 2 +- homeassistant/components/homematicip_cloud/errors.py | 2 +- homeassistant/components/homematicip_cloud/hap.py | 2 +- tests/components/homematicip_cloud/__init__.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index e18631c7049b..46934718b7ef 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -48,7 +48,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the HomematicIP Cloud component.""" + """Set up the HomematicIP Cloud integration.""" accesspoints = config.get(DOMAIN, []) for conf in accesspoints: diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index aae5a6227e37..1fe8b126ea62 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the HomematicIP Cloud component.""" +"""Config flow to configure the HomematicIP Cloud integration.""" from collections.abc import Mapping from typing import Any, override @@ -12,7 +12,7 @@ from .hap import HomematicipAuth class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): - """Config flow for the HomematicIP Cloud component.""" + """Config flow for the HomematicIP Cloud integration.""" VERSION = 2 diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 07e4fbadeb7a..60caa168311e 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -1,4 +1,4 @@ -"""Constants for the HomematicIP Cloud component.""" +"""Constants for the HomematicIP Cloud integration.""" import logging diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 2e4889a53d8c..5e2947b902fe 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -1,4 +1,4 @@ -"""Generic entity for the HomematicIP Cloud component.""" +"""Generic entity for the HomematicIP Cloud integration.""" import contextlib import logging diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py index bbee58f7a417..dc753cf62cb4 100644 --- a/homeassistant/components/homematicip_cloud/errors.py +++ b/homeassistant/components/homematicip_cloud/errors.py @@ -1,4 +1,4 @@ -"""Errors for the HomematicIP Cloud component.""" +"""Errors for the HomematicIP Cloud integration.""" from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index df54e669a584..65da63cd344f 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -1,4 +1,4 @@ -"""Access point for the HomematicIP Cloud component.""" +"""Access point for the HomematicIP Cloud integration.""" import asyncio from collections.abc import Callable diff --git a/tests/components/homematicip_cloud/__init__.py b/tests/components/homematicip_cloud/__init__.py index 1d89bd73183c..542a985b63ed 100644 --- a/tests/components/homematicip_cloud/__init__.py +++ b/tests/components/homematicip_cloud/__init__.py @@ -1 +1 @@ -"""Tests for the HomematicIP Cloud component.""" +"""Tests for the HomematicIP Cloud integration.""" From 0094fb592d3c57fc40eb74c7e375be0cd27f1657 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:22:35 +0200 Subject: [PATCH 033/707] Rename component to integration in GPSLogger (#175542) --- tests/components/gpslogger/__init__.py | 2 +- tests/components/gpslogger/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/gpslogger/__init__.py b/tests/components/gpslogger/__init__.py index 636a9a767f95..822253c30a11 100644 --- a/tests/components/gpslogger/__init__.py +++ b/tests/components/gpslogger/__init__.py @@ -1 +1 @@ -"""Tests for the GPSLogger component.""" +"""Tests for the GPSLogger integration.""" diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 4755c9031239..a172debf065b 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -64,7 +64,7 @@ async def setup_zones(hass: HomeAssistant) -> None: @pytest.fixture async def webhook_id(hass: HomeAssistant, gpslogger_client: TestClient) -> str: - """Initialize the GPSLogger component and get the webhook_id.""" + """Initialize the GPSLogger integration and get the webhook_id.""" await async_process_ha_core_config( hass, {"internal_url": "http://example.local:8123"}, From 7a4be51168240c7e99e846e863637d6ceeaad2ae Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:22:47 +0200 Subject: [PATCH 034/707] Add GetDateTime LLM tool to the llm integration (#175515) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/llm/llm.py | 43 ++++++++++++++++++++++++ tests/components/llm/test_init.py | 11 +++--- tests/components/llm/test_tools.py | 52 +++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/llm/llm.py create mode 100644 tests/components/llm/test_tools.py diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py new file mode 100644 index 000000000000..0e0ee54c65e3 --- /dev/null +++ b/homeassistant/components/llm/llm.py @@ -0,0 +1,43 @@ +"""LLM tools provided by the llm integration.""" + +from typing import override + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.llm import LLMContext, Tool, ToolInput +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType + +from . import LLMTools + + +class GetDateTimeTool(Tool): + """Tool for getting the current date and time.""" + + name = "GetDateTime" + description = "Provides the current date and time." + + @override + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current date and time.""" + now = dt_util.now() + + return { + "success": True, + "result": { + "date": now.strftime("%Y-%m-%d"), + "time": now.strftime("%H:%M:%S"), + "timezone": now.strftime("%Z"), + "weekday": now.strftime("%A"), + }, + } + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return the always-available LLM tools.""" + return LLMTools(tools=[GetDateTimeTool()]) diff --git a/tests/components/llm/test_init.py b/tests/components/llm/test_init.py index 88169df80d31..753b6d68c05d 100644 --- a/tests/components/llm/test_init.py +++ b/tests/components/llm/test_init.py @@ -71,18 +71,19 @@ async def test_get_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> No assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context) - assert result.tools == [tool] + # The llm integration also exposes its own GetDateTime tool (domain "llm"). + assert [tool.name for tool in result.tools] == ["GetDateTime", "my_tool"] assert result.prompt == "use my_tool wisely" async def test_get_tools_empty( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: - """Test that no platforms yields no tools.""" + """Test that only the llm integration's own tools are returned by default.""" assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context) - assert result.tools == [] + assert [tool.name for tool in result.tools] == ["GetDateTime"] assert result.prompt is None @@ -99,7 +100,7 @@ async def test_get_tools_merges_sorted( assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context) - assert result.tools == [tool_a, tool_b] + assert [tool.name for tool in result.tools] == ["GetDateTime", "tool_a", "tool_b"] assert result.prompt == "prompt a\nprompt b" @@ -116,6 +117,6 @@ async def test_get_tools_isolates_failing_platform( assert await async_setup_component(hass, "llm", {}) result = await async_get_tools(hass, llm_context) - assert result.tools == [tool] + assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] assert result.prompt == "prompt" assert "Error getting tools from LLM platform test_bad" in caplog.text diff --git a/tests/components/llm/test_tools.py b/tests/components/llm/test_tools.py new file mode 100644 index 000000000000..130726ba3d24 --- /dev/null +++ b/tests/components/llm/test_tools.py @@ -0,0 +1,52 @@ +"""Tests for the LLM integration's own tools platform.""" + +from freezegun import freeze_time +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations for the llm tools platform.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "llm", {}) + await hass.config.async_set_time_zone("UTC") + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_datetime_tool(hass: HomeAssistant) -> None: + """Test the GetDateTime tool is always offered and returns the current time.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context) + tool = next((tool for tool in result.tools if tool.name == "GetDateTime"), None) + assert tool is not None + + with freeze_time("2025-09-17 13:00:00"): + response = await tool.async_call( + hass, llm.ToolInput("GetDateTime", {}), llm_context + ) + + assert response == { + "success": True, + "result": { + "date": "2025-09-17", + "time": "13:00:00", + "timezone": "UTC", + "weekday": "Wednesday", + }, + } From b1c2c96438852a92c2a07de88b9d2566a09c6ae3 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:23:00 +0200 Subject: [PATCH 035/707] Rename component to integration in GitLab-CI (#175541) --- homeassistant/components/gitlab_ci/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/gitlab_ci/__init__.py b/homeassistant/components/gitlab_ci/__init__.py index 93b2a08c714a..27d82026ab9d 100644 --- a/homeassistant/components/gitlab_ci/__init__.py +++ b/homeassistant/components/gitlab_ci/__init__.py @@ -1 +1 @@ -"""The gitlab_ci component.""" +"""The GitLab-CI integration.""" From 3317aec38c4446d8abd8f71ea56b0087d2e6249c Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:23:18 +0200 Subject: [PATCH 036/707] Rename component to integration in Foobot (#175539) --- homeassistant/components/foobot/__init__.py | 2 +- tests/components/foobot/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/foobot/__init__.py b/homeassistant/components/foobot/__init__.py index 92edde9a5e1c..8afa3abd2348 100644 --- a/homeassistant/components/foobot/__init__.py +++ b/homeassistant/components/foobot/__init__.py @@ -1 +1 @@ -"""The foobot component.""" +"""The Foobot integration.""" diff --git a/tests/components/foobot/__init__.py b/tests/components/foobot/__init__.py index 88d916d997f1..a39cf589a88f 100644 --- a/tests/components/foobot/__init__.py +++ b/tests/components/foobot/__init__.py @@ -1 +1 @@ -"""Tests for the foobot component.""" +"""Tests for the Foobot integration.""" From dcd5d15e028aad34bccea462474d73d30867a67f Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:23:41 +0200 Subject: [PATCH 037/707] Rename component to integration in Flock (#175537) --- homeassistant/components/flock/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flock/__init__.py b/homeassistant/components/flock/__init__.py index 1b58d21cff88..cb299b9f4217 100644 --- a/homeassistant/components/flock/__init__.py +++ b/homeassistant/components/flock/__init__.py @@ -1 +1 @@ -"""The flock component.""" +"""The Flock integration.""" From 79ae3cd3d407096a8b730228e74c8b644e1a3313 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Fri, 3 Jul 2026 20:24:09 +0200 Subject: [PATCH 038/707] Rename component to integration in FinTS (#175536) --- homeassistant/components/fints/__init__.py | 2 +- tests/components/fints/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fints/__init__.py b/homeassistant/components/fints/__init__.py index 0113fa752346..030236752ad1 100644 --- a/homeassistant/components/fints/__init__.py +++ b/homeassistant/components/fints/__init__.py @@ -1 +1 @@ -"""The fints component.""" +"""The FinTS integration.""" diff --git a/tests/components/fints/__init__.py b/tests/components/fints/__init__.py index 6a2b1d96d206..b733febd899e 100644 --- a/tests/components/fints/__init__.py +++ b/tests/components/fints/__init__.py @@ -1 +1 @@ -"""Tests for FinTS component.""" +"""Tests for FinTS integration.""" From f57701e32f372417506cb56897230b989cb9fe51 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:47:06 +0200 Subject: [PATCH 039/707] Add lawn_mower LLM tools platform (#175523) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/lawn_mower/llm.py | 33 ++++++++++++++ tests/components/lawn_mower/test_llm.py | 52 ++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 homeassistant/components/lawn_mower/llm.py create mode 100644 tests/components/lawn_mower/test_llm.py diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py new file mode 100644 index 000000000000..2458a8df74e2 --- /dev/null +++ b/homeassistant/components/lawn_mower/llm.py @@ -0,0 +1,33 @@ +"""LLM tools for the lawn_mower integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING) + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return LLMTools(tools=[]) + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/lawn_mower/test_llm.py b/tests/components/lawn_mower/test_llm.py new file mode 100644 index 000000000000..818f9f3295e3 --- /dev/null +++ b/tests/components/lawn_mower/test_llm.py @@ -0,0 +1,52 @@ +"""Tests for the lawn_mower LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "lawn_mower.test" +INTENTS = {"HassLawnMowerDock", "HassLawnMowerStartMowing"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a lawn_mower entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "lawn_mower", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test lawn_mower"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the lawn_mower platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed lawn_mower entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no lawn_mower entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) From 9a3987d10988f83f884b399931a7e132d7a0fbdd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:54:23 +0200 Subject: [PATCH 040/707] Add todo LLM tools platform (#175513) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/todo/llm.py | 115 +++++++++++++++++++++++++ tests/components/todo/test_llm.py | 120 +++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 homeassistant/components/todo/llm.py create mode 100644 tests/components/todo/test_llm.py diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py new file mode 100644 index 000000000000..eaf365a288ab --- /dev/null +++ b/homeassistant/components/todo/llm.py @@ -0,0 +1,115 @@ +"""LLM tools for the todo integration.""" + +from operator import attrgetter +from typing import Any, cast, override + +import voluptuous as vol + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool, ToolInput +from homeassistant.util.json import JsonObjectType + +from .const import DOMAIN, TodoServices +from .intent import ( + INTENT_LIST_ADD_ITEM, + INTENT_LIST_COMPLETE_ITEM, + INTENT_LIST_REMOVE_ITEM, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_LIST_ADD_ITEM, INTENT_LIST_COMPLETE_ITEM, INTENT_LIST_REMOVE_ITEM) + + +class TodoGetItemsTool(Tool): + """LLM Tool allowing querying a to-do list.""" + + name = "todo_get_items" + description = ( + "Query a to-do list to find out what items are on it. " + "Use this to answer questions like " + "'What's on my task list?' or " + "'Read my grocery list'. " + "Filters items by status (needs_action, completed, all)." + ) + + def __init__(self, todo_lists: list[str]) -> None: + """Init the get items tool.""" + self.parameters = vol.Schema( + { + vol.Required("todo_list"): vol.In(todo_lists), + vol.Optional( + "status", + description=( + "Filter returned items by status," + " by default returns incomplete" + " items" + ), + default="needs_action", + ): vol.In(["needs_action", "completed", "all"]), + } + ) + + @override + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a to-do list.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["todo_list"], + domains=[DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "To-do list not found"} + entity_id = result.states[0].entity_id + service_data: dict[str, Any] = {"entity_id": entity_id} + if status := data.get("status"): + if status == "all": + service_data["status"] = ["needs_action", "completed"] + else: + service_data["status"] = [status] + service_result = await hass.services.async_call( + DOMAIN, + TodoServices.GET_ITEMS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + if not service_result: + return {"success": False, "error": "To-do list not found"} + items = cast(dict, service_result)[entity_id]["items"] + return {"success": True, "result": items} + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return the todo LLM tools when a to-do list is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + entity_registry = er.async_get(hass) + names: list[str] = [] + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): + if not async_should_expose(hass, llm_context.assistant, state.entity_id): + continue + entity_entry = entity_registry.async_get(state.entity_id) + names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) + + if not names: + return LLMTools(tools=[]) + + tools: list[Tool] = [TodoGetItemsTool(names)] + tools.extend( + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ) + return LLMTools(tools=tools) diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py new file mode 100644 index 000000000000..a25332d886cf --- /dev/null +++ b/tests/components/todo/test_llm.py @@ -0,0 +1,120 @@ +"""Tests for the todo LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component, todo +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import config_validation as cv, llm +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + +ENTITY_ID = "todo.test_list" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a to-do list.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "todo", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "0", {"friendly_name": "Mock Todo List Name"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_tools_no_exposed_todo(hass: HomeAssistant) -> None: + """Test no todo tool is offered when no to-do list is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context()) + assert [tool.name for tool in result.tools] == [] + + +async def test_todo_get_items_tool(hass: HomeAssistant) -> None: + """Test the todo get items tool is exposed and works via the platform.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context) + tool = next((tool for tool in result.tools if tool.name == "todo_get_items"), None) + assert tool is not None + assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ + ENTITY_ID: { + "items": [ + {"uid": "1234", "summary": "Buy milk", "status": "needs_action"}, + ] + } + }, + ) + + result = await tool.async_call( + hass, + llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), + llm_context, + ) + + assert len(calls) == 1 + assert calls[0].data == {"entity_id": [ENTITY_ID], "status": ["needs_action"]} + assert result == { + "success": True, + "result": [{"uid": "1234", "status": "needs_action", "summary": "Buy milk"}], + } + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + ("all", ["needs_action", "completed"]), + ("completed", ["completed"]), + ], +) +async def test_todo_get_items_status_filter( + hass: HomeAssistant, status: str, expected: list[str] +) -> None: + """Test the status filter is translated into the service call.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context) + tool = next(tool for tool in result.tools if tool.name == "todo_get_items") + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ENTITY_ID: {"items": []}}, + ) + await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", {"todo_list": "Mock Todo List Name", "status": status} + ), + llm_context, + ) + assert calls[0].data == {"entity_id": [ENTITY_ID], "status": expected} + + +async def test_todo_list_intents_exposed(hass: HomeAssistant) -> None: + """Test the todo list intents are exposed as tools when a list is exposed.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + names = {tool.name for tool in result.tools} + assert "HassListAddItem" in names + assert "HassListCompleteItem" in names + assert "HassListRemoveItem" in names From 846d1716b18ee97269e2294630d50077b71370b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:55:11 +0200 Subject: [PATCH 041/707] Add media_player LLM tools platform (#175524) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/media_player/llm.py | 53 +++++++++++++++++ tests/components/media_player/test_llm.py | 62 ++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 homeassistant/components/media_player/llm.py create mode 100644 tests/components/media_player/test_llm.py diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py new file mode 100644 index 000000000000..5f94dc9932e5 --- /dev/null +++ b/homeassistant/components/media_player/llm.py @@ -0,0 +1,53 @@ +"""LLM tools for the media_player integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import IntentTool, LLMContext, Tool + +from .const import ( + DOMAIN, + INTENT_MEDIA_NEXT, + INTENT_MEDIA_PAUSE, + INTENT_MEDIA_PREVIOUS, + INTENT_MEDIA_SEARCH_AND_PLAY, + INTENT_MEDIA_UNPAUSE, + INTENT_PLAYER_MUTE, + INTENT_PLAYER_UNMUTE, + INTENT_SET_VOLUME, + INTENT_SET_VOLUME_RELATIVE, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = ( + INTENT_MEDIA_NEXT, + INTENT_MEDIA_PAUSE, + INTENT_PLAYER_MUTE, + INTENT_PLAYER_UNMUTE, + INTENT_MEDIA_PREVIOUS, + INTENT_MEDIA_SEARCH_AND_PLAY, + INTENT_MEDIA_UNPAUSE, + INTENT_SET_VOLUME, + INTENT_SET_VOLUME_RELATIVE, +) + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return LLMTools(tools=[]) + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py new file mode 100644 index 000000000000..869692e8b5ec --- /dev/null +++ b/tests/components/media_player/test_llm.py @@ -0,0 +1,62 @@ +"""Tests for the media_player LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "media_player.test" +INTENTS = { + "HassMediaNext", + "HassMediaPause", + "HassMediaPlayerMute", + "HassMediaPlayerUnmute", + "HassMediaPrevious", + "HassMediaSearchAndPlay", + "HassMediaUnpause", + "HassSetVolume", + "HassSetVolumeRelative", +} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a media_player entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "media_player", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test media_player"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the media_player platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed media_player entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no media_player entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) From e90d2f9ca0be99f11b3946c6470b2909cbfa7e6a Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:26:43 +0300 Subject: [PATCH 042/707] Fix swallowed exceptions in airobot action handlers (#172633) --- homeassistant/components/airobot/button.py | 12 +++++++++--- tests/components/airobot/test_button.py | 11 ++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py index 24adef5dbcd1..46bb3847219c 100644 --- a/homeassistant/components/airobot/button.py +++ b/homeassistant/components/airobot/button.py @@ -32,6 +32,7 @@ class AirobotButtonEntityDescription(ButtonEntityDescription): """Describes Airobot button entity.""" press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]] + ignore_connection_errors: bool = False BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( @@ -40,6 +41,7 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda coordinator: coordinator.client.reboot_thermostat(), + ignore_connection_errors=True, ), AirobotButtonEntityDescription( key="recalibrate_co2", @@ -84,10 +86,14 @@ class AirobotButton(AirobotEntity, ButtonEntity): """Handle the button press.""" try: await self.entity_description.press_fn(self.coordinator) - # pylint: disable-next=home-assistant-action-swallowed-exception - except AirobotConnectionError, AirobotTimeoutError: + except (AirobotConnectionError, AirobotTimeoutError) as err: + if not self.entity_description.ignore_connection_errors: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="button_press_failed", + translation_placeholders={"button": self.entity_description.key}, + ) from err # Connection errors during reboot are expected as device restarts - pass except AirobotError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py index 529605836a99..59b50b05d31e 100644 --- a/tests/components/airobot/test_button.py +++ b/tests/components/airobot/test_button.py @@ -112,12 +112,21 @@ async def test_recalibrate_co2_button( @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + "exception", + [ + AirobotError("Test error"), + AirobotConnectionError("Connection lost"), + AirobotTimeoutError("Timeout"), + ], +) async def test_recalibrate_co2_button_error( hass: HomeAssistant, mock_airobot_client: AsyncMock, + exception: Exception, ) -> None: """Test recalibrate CO2 sensor button error handling.""" - mock_airobot_client.recalibrate_co2_sensor.side_effect = AirobotError("Test error") + mock_airobot_client.recalibrate_co2_sensor.side_effect = exception with pytest.raises(HomeAssistantError): await hass.services.async_call( From 4793ca23e3b39bf96b8bc111db6904a432e6157a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 21:36:27 +0200 Subject: [PATCH 043/707] Fix todo LLM tools test broken by GetDateTime tool (#175554) Co-authored-by: Claude Opus 4.8 (1M context) --- tests/components/todo/test_llm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py index a25332d886cf..dccaeb880b50 100644 --- a/tests/components/todo/test_llm.py +++ b/tests/components/todo/test_llm.py @@ -40,7 +40,7 @@ async def test_get_tools_no_exposed_todo(hass: HomeAssistant) -> None: """Test no todo tool is offered when no to-do list is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) result = await llm_component.async_get_tools(hass, _llm_context()) - assert [tool.name for tool in result.tools] == [] + assert "todo_get_items" not in [tool.name for tool in result.tools] async def test_todo_get_items_tool(hass: HomeAssistant) -> None: From 7dac0506d30844ec135924430ffcb586302ebf21 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 21:54:54 +0200 Subject: [PATCH 044/707] Make LLMContext.assistant a required non-optional field (#175553) Co-authored-by: Claude --- homeassistant/components/intent_script/llm.py | 21 +++++++++---------- homeassistant/helpers/llm.py | 7 +------ tests/helpers/test_llm.py | 4 +--- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/intent_script/llm.py b/homeassistant/components/intent_script/llm.py index 81e9e6d91cd1..da828eba3e9d 100644 --- a/homeassistant/components/intent_script/llm.py +++ b/homeassistant/components/intent_script/llm.py @@ -20,17 +20,16 @@ def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: if isinstance(handler, ScriptIntentHandler) ] - if llm_context.assistant is not None: - exposed_domains = { - state.domain - for state in hass.states.async_all() - if async_should_expose(hass, llm_context.assistant, state.entity_id) - } - handlers = [ - handler - for handler in handlers - if handler.platforms is None or handler.platforms & exposed_domains - ] + exposed_domains = { + state.domain + for state in hass.states.async_all() + if async_should_expose(hass, llm_context.assistant, state.entity_id) + } + handlers = [ + handler + for handler in handlers + if handler.platforms is None or handler.platforms & exposed_domains + ] # Intent script names come from user configuration, so slugify them into # valid tool names. diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index f89608f793f7..64ea03a869f1 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -191,7 +191,7 @@ class LLMContext: language: str | None """Language of the LLM request.""" - assistant: str | None + assistant: str """Assistant domain that is handling the LLM request.""" device_id: str | None @@ -1278,11 +1278,6 @@ class GetLiveContextTool(Tool): llm_context: LLMContext, ) -> JsonObjectType: """Get the current state of exposed entities.""" - if llm_context.assistant is None: - # Note this doesn't happen in practice since this tool won't be - # exposed if no assistant is configured. - return {"success": False, "error": "No assistant configured"} - args = self.parameters(tool_input.tool_args) exposed_entities = _get_exposed_entities(hass, llm_context.assistant) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 8f2a978a14eb..bf363d695600 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -38,7 +38,7 @@ def llm_context() -> llm.LLMContext: platform="", context=None, language=None, - assistant=None, + assistant="conversation", device_id=None, ) @@ -367,8 +367,6 @@ async def test_assist_api_tools( assert [tool.name for tool in api.tools] == [ "HassTurnOn", "HassTurnOff", - "HassSetPosition", - "HassStopMoving", "HassStartTimer", "HassCancelTimer", "HassCancelAllTimers", From fa931b3496722ec940d68620299ede36aef24460 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 22:15:42 +0200 Subject: [PATCH 045/707] Use cached state JSON in POST /api/states response (#175559) Co-authored-by: Claude --- homeassistant/components/api/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ded5f8e57be6..c6357ea4c3fd 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -297,11 +297,12 @@ class APIEntityStateView(HomeAssistantView): return self.json_message( "Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR ) - resp = self.json(state.as_dict(), status_code) - - resp.headers.add("Location", f"/api/states/{entity_id}") - - return resp + return web.Response( + body=state.as_dict_json, + content_type=CONTENT_TYPE_JSON, + status=status_code, + headers={"Location": f"/api/states/{entity_id}"}, + ) @ha.callback def delete(self, request: web.Request, entity_id: str) -> web.Response: From fe8b145a844a0ca3784e1518322167b14b385fca Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 3 Jul 2026 22:16:33 +0200 Subject: [PATCH 046/707] Add search functionality to media source (#175485) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/demo/media_player.py | 11 +- .../components/media_source/__init__.py | 3 +- .../components/media_source/helper.py | 35 ++++- homeassistant/components/media_source/http.py | 36 ++++- .../components/media_source/local_source.py | 77 ++++++++++- .../components/media_source/models.py | 23 +++- .../components/media_source/strings.json | 6 + tests/components/demo/test_media_player.py | 28 ++++ .../heos/snapshots/test_media_player.ambr | 4 +- tests/components/media_source/test_helper.py | 62 ++++++++- tests/components/media_source/test_http.py | 76 ++++++++++- .../media_source/test_local_source.py | 124 +++++++++++++++++- tests/components/media_source/test_models.py | 16 ++- 13 files changed, 488 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index df35a749ee20..f0731f8f0a78 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -407,9 +407,9 @@ class DemoTVShowPlayer(AbstractDemoPlayer): class DemoBrowsePlayer(AbstractDemoPlayer): - """A Demo media player that supports browse.""" + """A Demo media player that supports browse and search.""" - _attr_supported_features = BROWSE_PLAYER_SUPPORT + _attr_supported_features = BROWSE_PLAYER_SUPPORT | SEARCH_PLAYER_SUPPORT @override async def async_browse_media( @@ -421,6 +421,13 @@ class DemoBrowsePlayer(AbstractDemoPlayer): return await media_source.async_browse_media(self.hass, media_content_id) + @override + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Implement the websocket media search helper by delegating to media source.""" + return await media_source.async_search_media( + self.hass, query.media_content_id, query + ) + class DemoGroupPlayer(AbstractDemoPlayer): """A Demo media player that supports grouping.""" diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index e2d30db21004..030f68bf4142 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -20,7 +20,7 @@ from .const import ( URI_SCHEME_REGEX, ) from .error import MediaSourceError, Unresolvable -from .helper import async_browse_media, async_resolve_media +from .helper import async_browse_media, async_resolve_media, async_search_media from .models import ( BrowseMediaSource, MediaSource, @@ -42,6 +42,7 @@ __all__ = [ "Unresolvable", "async_browse_media", "async_resolve_media", + "async_search_media", "generate_media_source_id", "is_media_source_id", ] diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py index 0d7afc2b81c3..099774deaa8d 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -2,7 +2,12 @@ from collections.abc import Callable -from homeassistant.components.media_player import BrowseError, BrowseMedia +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + SearchMedia, + SearchMediaQuery, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.frame import report_usage from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -67,6 +72,34 @@ async def async_browse_media( return item +async def async_search_media( + hass: HomeAssistant, + media_content_id: str | None, + query: SearchMediaQuery, +) -> SearchMedia: + """Return media searched in the media source.""" + if DOMAIN not in hass.data: + raise BrowseError("Media Source not loaded") + + try: + return await _get_media_item(hass, media_content_id, None).async_search(query) + except NotImplementedError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="search_not_supported", + translation_placeholders={"media_content_id": str(media_content_id)}, + ) from err + except ValueError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="search_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err + + async def async_resolve_media( hass: HomeAssistant, media_content_id: str, diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py index c1c4882e7acf..0acec90fae88 100644 --- a/homeassistant/components/media_source/http.py +++ b/homeassistant/components/media_source/http.py @@ -7,20 +7,25 @@ import voluptuous as vol from homeassistant.components import frontend, websocket_api from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_FILTER_CLASSES, + ATTR_MEDIA_SEARCH_QUERY, CONTENT_AUTH_EXPIRY_TIME, BrowseError, + MediaClass, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant from .error import Unresolvable -from .helper import async_browse_media, async_resolve_media +from .helper import async_browse_media, async_resolve_media, async_search_media def async_setup(hass: HomeAssistant) -> None: """Set up the HTTP views and WebSocket commands for media sources.""" websocket_api.async_register_command(hass, websocket_browse_media) + websocket_api.async_register_command(hass, websocket_search_media) websocket_api.async_register_command(hass, websocket_resolve_media) frontend.async_register_built_in_panel( hass, "media-browser", "media_browser", "mdi:play-box-multiple" @@ -48,6 +53,35 @@ async def websocket_browse_media( connection.send_error(msg["id"], "browse_media_failed", str(err)) +@websocket_api.websocket_command( + { + vol.Required("type"): "media_source/search_media", + vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str, + vol.Required(ATTR_MEDIA_SEARCH_QUERY): str, + vol.Optional(ATTR_MEDIA_FILTER_CLASSES): [vol.Coerce(MediaClass)], + } +) +@websocket_api.async_response +async def websocket_search_media( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Search available media.""" + try: + result = await async_search_media( + hass, + msg["media_content_id"], + SearchMediaQuery( + search_query=msg[ATTR_MEDIA_SEARCH_QUERY], + media_filter_classes=msg.get(ATTR_MEDIA_FILTER_CLASSES), + ), + ) + except BrowseError as err: + connection.send_error(msg["id"], "search_media_failed", str(err)) + return + + connection.send_result(msg["id"], result.as_dict()) + + @websocket_api.websocket_command( { vol.Required("type"): "media_source/resolve_media", diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 3c4bd81c2aee..5d9ca3fb581e 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -13,7 +13,13 @@ import voluptuous as vol from homeassistant.components import http, websocket_api from homeassistant.components.http import require_admin -from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + SearchMedia, + SearchMediaQuery, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -23,6 +29,7 @@ from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia MAX_UPLOAD_SIZE = 1024 * 1024 * 20 +MAX_SEARCH_RESULTS = 100 LOGGER = logging.getLogger(__name__) @@ -175,6 +182,72 @@ class LocalSource(MediaSource): self._browse_media, source_dir_id, location ) + @override + async def async_search_media( + self, item: MediaSourceItem, query: SearchMediaQuery + ) -> SearchMedia: + """Search media by file name within the local media directories.""" + if item.identifier: + try: + source_dir_id, location = self.async_parse_identifier(item) + except Unresolvable as err: + raise BrowseError(str(err)) from err + search_dirs = [(source_dir_id, location)] + else: + search_dirs = [(source_dir_id, "") for source_dir_id in self.media_dirs] + + return await self.hass.async_add_executor_job( + self._search_media, search_dirs, query + ) + + def _search_media( + self, search_dirs: list[tuple[str, str]], query: SearchMediaQuery + ) -> SearchMedia: + """Search media files by name (runs in the executor).""" + query_str = query.search_query.casefold() + filter_classes = set(query.media_filter_classes or ()) + results: list[BrowseMedia] = [] + + for source_dir_id, location in search_dirs: + if len(results) >= MAX_SEARCH_RESULTS: + break + base_path = Path(self.media_dirs[source_dir_id]) + search_path = base_path / location + if not search_path.is_dir(): + continue + + # Traverse lazily so MAX_SEARCH_RESULTS can short-circuit large libraries + for path in search_path.rglob("*"): + if len(results) >= MAX_SEARCH_RESULTS: + break + relative = path.relative_to(base_path) + if any(part.startswith(".") for part in relative.parts): + continue + if query_str not in path.name.casefold() or not path.is_file(): + continue + mime_type, _ = mimetypes.guess_type(str(path)) + if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: + continue + media_class = MEDIA_CLASS_MAP.get( + mime_type.split("/")[0], MediaClass.DIRECTORY + ) + if filter_classes and media_class not in filter_classes: + continue + results.append( + BrowseMediaSource( + domain=self.domain, + identifier=f"{source_dir_id}/{relative}", + media_class=media_class, + media_content_type=mime_type, + title=path.name, + can_play=True, + can_expand=False, + ) + ) + + results.sort(key=lambda item: item.title) + return SearchMedia(result=results) + def _browse_media( self, source_dir_id: str | None, location: str ) -> BrowseMediaSource: @@ -197,6 +270,7 @@ class LocalSource(MediaSource): title=self.name, can_play=False, can_expand=True, + can_search=True, children_media_class=MediaClass.DIRECTORY, ) @@ -255,6 +329,7 @@ class LocalSource(MediaSource): title=title, can_play=is_file, can_expand=is_dir, + can_search=is_dir, ) if is_file or is_child: diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index c02cee7b9b95..fdf34c9f594b 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -3,7 +3,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType +from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, + MediaType, + SearchMedia, + SearchMediaQuery, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.translation import async_get_cached_translations @@ -101,6 +107,15 @@ class MediaSourceItem: return await self.async_media_source().async_browse_media(self) + async def async_search(self, query: SearchMediaQuery) -> SearchMedia: + """Search this item.""" + # Searching the aggregate root (no specific source) is currently not supported + # because it would possibly returns 100s of items + if self.domain is None: + raise NotImplementedError + + return await self.async_media_source().async_search_media(self, query) + async def async_resolve(self) -> PlayMedia: """Resolve to playable item.""" return await self.async_media_source().async_resolve_media(self) @@ -144,3 +159,9 @@ class MediaSource: async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Browse media.""" raise NotImplementedError + + async def async_search_media( + self, item: MediaSourceItem, query: SearchMediaQuery + ) -> SearchMedia: + """Search media.""" + raise NotImplementedError diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json index 607f48f66523..d12c41301909 100644 --- a/homeassistant/components/media_source/strings.json +++ b/homeassistant/components/media_source/strings.json @@ -9,6 +9,12 @@ "resolve_media_failed": { "message": "Failed to resolve media with content id {media_content_id}: {error}" }, + "search_media_failed": { + "message": "Failed to search media with content id {media_content_id}: {error}" + }, + "search_not_supported": { + "message": "Search is not supported for media with content id {media_content_id}" + }, "unknown_media_source": { "message": "Unknown media source: {domain}" } diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index e2aca44ee2b1..112af447333b 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -615,3 +615,31 @@ async def test_browse( assert msg["result"]["title"] == "media" assert msg["result"]["media_class"] == "directory" assert len(msg["result"]["children"]) + + +async def test_search( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the media player search delegates to media source.""" + entity = "media_player.browse" + + await async_setup_component(hass, "media_source", {"media_source": {}}) + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": entity, + "search_query": "test", + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert [item["title"] for item in msg["result"]["result"]] == ["test.mp3"] diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 72218dc50ed4..148988f8a9ad 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -46,7 +46,7 @@ dict({ 'can_expand': True, 'can_play': False, - 'can_search': False, + 'can_search': True, 'children': list([ dict({ 'can_expand': False, @@ -100,7 +100,7 @@ dict({ 'can_expand': True, 'can_play': False, - 'can_search': False, + 'can_search': True, 'children_media_class': 'music', 'media_class': 'directory', 'media_content_id': 'media-source://media_source/local/.', diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py index 824a27f6efb1..03be63acc4bc 100644 --- a/tests/components/media_source/test_helper.py +++ b/tests/components/media_source/test_helper.py @@ -5,8 +5,13 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_player import ( + BrowseError, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.media_source import const, models +from homeassistant.components.media_source.const import MEDIA_SOURCE_DATA from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -125,5 +130,60 @@ async def test_browse_resolve_without_setup() -> None: with pytest.raises(BrowseError): await media_source.async_browse_media(Mock(data={}), None) + with pytest.raises(BrowseError): + await media_source.async_search_media( + Mock(data={}), None, SearchMediaQuery(search_query="test") + ) + with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media(Mock(data={}), None, None) + + +async def test_async_search_media(hass: HomeAssistant) -> None: + """Test search media helper.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + # Search the default media directory by file name + result = await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="test") + ) + assert isinstance(result, SearchMedia) + assert [item.title for item in result.result] == ["test.mp3"] + + # A query without matches returns an empty result + result = await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="no-such-file") + ) + assert result.result == [] + + # Invalid media content raises a BrowseError + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, "invalid", SearchMediaQuery(search_query="test") + ) + + +async def test_async_search_media_not_supported(hass: HomeAssistant) -> None: + """Test searching a source without search support raises a BrowseError.""" + hass.data[MEDIA_SOURCE_DATA] = {"plain": models.MediaSource("plain")} + + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}plain", + SearchMediaQuery(search_query="test"), + ) + + +async def test_async_search_media_root_not_supported(hass: HomeAssistant) -> None: + """Test searching the aggregate root of multiple sources is not supported.""" + hass.data[MEDIA_SOURCE_DATA] = { + "source_a": models.MediaSource("source_a"), + "source_b": models.MediaSource("source_b"), + } + + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="test") + ) diff --git a/tests/components/media_source/test_http.py b/tests/components/media_source/test_http.py index c5f487f27cfa..c826c7cdd8dd 100644 --- a/tests/components/media_source/test_http.py +++ b/tests/components/media_source/test_http.py @@ -6,7 +6,12 @@ import pytest import yarl from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_player import ( + BrowseError, + MediaClass, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.media_source import const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -127,3 +132,72 @@ async def test_websocket_resolve_media( assert not msg["success"] assert msg["error"]["code"] == "resolve_media_failed" assert msg["error"]["message"] == "test" + + +async def test_websocket_search_media( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test search media websocket.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + search_media = SearchMedia( + result=[ + media_source.models.BrowseMediaSource( + domain=media_source.DOMAIN, + identifier="/media/test.mp3", + title="test.mp3", + media_class=MediaClass.MUSIC, + media_content_type="audio/mpeg", + can_play=True, + can_expand=False, + ) + ] + ) + + with patch( + "homeassistant.components.media_source.http.async_search_media", + return_value=search_media, + ) as mock_search: + await client.send_json( + { + "id": 1, + "type": "media_source/search_media", + "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}", + "search_query": "test", + "media_filter_classes": ["music"], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["id"] == 1 + assert msg["result"] == search_media.as_dict() + + # The query is built from the websocket message, coercing the filter classes + query = mock_search.call_args[0][2] + assert isinstance(query, SearchMediaQuery) + assert query.search_query == "test" + assert query.media_filter_classes == [MediaClass.MUSIC] + + with patch( + "homeassistant.components.media_source.http.async_search_media", + side_effect=BrowseError("test"), + ): + await client.send_json( + { + "id": 2, + "type": "media_source/search_media", + "media_content_id": "invalid", + "search_query": "test", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "search_media_failed" + assert msg["error"]["message"] == "test" diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index cf63ad9d6884..47a7b030de79 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -10,8 +10,13 @@ from unittest.mock import patch import pytest from homeassistant.components import media_source, websocket_api -from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_player import ( + BrowseError, + MediaClass, + SearchMediaQuery, +) from homeassistant.components.media_source import const +from homeassistant.components.media_source.local_source import MAX_SEARCH_RESULTS from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component @@ -90,6 +95,123 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: assert media +async def test_async_search_media(hass: HomeAssistant) -> None: + """Test searching local media.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # Search within a single directory (contextual) + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="test"), + ) + assert [item.title for item in result.result] == ["test.mp3"] + assert ( + result.result[0].media_content_id + == f"{const.URI_SCHEME}{const.DOMAIN}/local/test.mp3" + ) + + # Search across all directories (global) finds the file in both dirs + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}", + SearchMediaQuery(search_query="sax"), + ) + assert {item.title for item in result.result} == {"Epic Sax Guy 10 Hours.mp4"} + assert len(result.result) == 2 + + # Non-media files are not returned + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="not_media"), + ) + assert result.result == [] + + # Searching a non-existent directory returns no results + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local/nonexistent", + SearchMediaQuery(search_query="test"), + ) + assert result.result == [] + + # Filter by media class + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="", media_filter_classes=[MediaClass.MUSIC]), + ) + assert [item.title for item in result.result] == ["test.mp3"] + + # Invalid path raises a BrowseError + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local/../secret", + SearchMediaQuery(search_query="test"), + ) + + +async def test_async_search_media_limit_and_hidden( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test that search caps results and skips hidden files.""" + for i in range(MAX_SEARCH_RESULTS + 20): + (tmp_path / f"song_{i}.mp3").touch() + (tmp_path / ".hidden_song.mp3").touch() + + await async_process_ha_core_config( + hass, {"media_dirs": {"local": str(tmp_path), "recordings": str(tmp_path)}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # Global search across both dirs; the first dir already fills the limit + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}", + SearchMediaQuery(search_query="song"), + ) + assert len(result.result) == MAX_SEARCH_RESULTS + assert all(not item.title.startswith(".") for item in result.result) + + +async def test_browse_media_can_search(hass: HomeAssistant) -> None: + """Test that browsable directories advertise search support.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # The root of multiple directories is searchable + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}" + ) + assert media.can_search + + # A directory is searchable, but the files inside it are not + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/." + ) + assert media.can_search + file_child = next(child for child in media.children if child.can_play) + assert not file_child.can_search + + async def test_media_view( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: diff --git a/tests/components/media_source/test_models.py b/tests/components/media_source/test_models.py index 1ed03a839615..ad4662c4d2b5 100644 --- a/tests/components/media_source/test_models.py +++ b/tests/components/media_source/test_models.py @@ -1,6 +1,12 @@ """Test Media Source model methods.""" -from homeassistant.components.media_player import MediaClass, MediaType +import pytest + +from homeassistant.components.media_player import ( + MediaClass, + MediaType, + SearchMediaQuery, +) from homeassistant.components.media_source import const, models from homeassistant.core import HomeAssistant @@ -84,3 +90,11 @@ async def test_media_source_item_media_source_id(hass: HomeAssistant) -> None: # Test with no domain (root) item = models.MediaSourceItem(hass, None, "", None) assert item.media_source_id == "media-source://" + + +async def test_media_source_search_media_not_implemented(hass: HomeAssistant) -> None: + """Test the base MediaSource.async_search_media raises NotImplementedError.""" + source = models.MediaSource(const.DOMAIN) + item = models.MediaSourceItem(hass, const.DOMAIN, "", None) + with pytest.raises(NotImplementedError): + await source.async_search_media(item, SearchMediaQuery(search_query="test")) From 2293f51828e6e3d3d51ac16259ff8cc97de1b8fc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 22:53:02 +0200 Subject: [PATCH 047/707] Add calendar LLM tools platform (#175512) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/calendar/llm.py | 101 ++++++++++++++ tests/components/calendar/test_llm.py | 165 +++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 homeassistant/components/calendar/llm.py create mode 100644 tests/components/calendar/test_llm.py diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py new file mode 100644 index 000000000000..53fd1fc2d4cc --- /dev/null +++ b/homeassistant/components/calendar/llm.py @@ -0,0 +1,101 @@ +"""LLM tools for the calendar integration.""" + +from datetime import timedelta +from operator import attrgetter +from typing import cast, override + +import voluptuous as vol + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import LLMContext, Tool, ToolInput +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType + +from . import SERVICE_GET_EVENTS +from .const import DOMAIN + + +class CalendarGetEventsTool(Tool): + """LLM Tool allowing querying a calendar.""" + + name = "calendar_get_events" + description = ( + "Get events from a calendar. " + "When asked if something happens, search the whole week. " + "Results are RFC 5545 which means 'end' is exclusive." + ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) + + @override + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a calendar.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["calendar"], + domains=[DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "Calendar not found"} + + entity_id = result.states[0].entity_id + if data["range"] == "today": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=1) + elif data["range"] == "week": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=7) + + service_data = { + "entity_id": entity_id, + "start_date_time": start.isoformat(), + "end_date_time": end.isoformat(), + } + + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_EVENTS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + + events = [ + event if "T" in event["start"] else {**event, "all_day": True} + for event in cast(dict, service_result)[entity_id]["events"] + ] + + return {"success": True, "result": events} + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return the calendar LLM tools when a calendar is exposed.""" + entity_registry = er.async_get(hass) + names: list[str] = [] + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): + if not async_should_expose(hass, llm_context.assistant, state.entity_id): + continue + entity_entry = entity_registry.async_get(state.entity_id) + names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) + + if not names: + return LLMTools(tools=[]) + return LLMTools(tools=[CalendarGetEventsTool(names)]) diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py new file mode 100644 index 000000000000..539dc54b7e95 --- /dev/null +++ b/tests/components/calendar/test_llm.py @@ -0,0 +1,165 @@ +"""Tests for the calendar LLM tools platform.""" + +from datetime import timedelta + +from freezegun import freeze_time +import pytest + +from homeassistant.components import calendar, llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant, SupportsResponse +from homeassistant.helpers import entity_registry as er, llm +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import async_mock_service + +ENTITY_ID = "calendar.test_calendar" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a calendar.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Mock Calendar Name"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_tools_no_exposed_calendar(hass: HomeAssistant) -> None: + """Test no calendar tool is offered when no calendar is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context()) + assert "calendar_get_events" not in [tool.name for tool in result.tools] + + +async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: + """Test the calendar get events tool is exposed and works via the platform.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context) + tool = next( + (tool for tool in result.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] + + calls = async_mock_service( + hass, + domain=calendar.DOMAIN, + service=calendar.SERVICE_GET_EVENTS, + schema=calendar.SERVICE_GET_EVENTS_SCHEMA, + response={ + ENTITY_ID: { + "events": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ] + } + }, + supports_response=SupportsResponse.ONLY, + ) + + tool_input = llm.ToolInput( + tool_name="calendar_get_events", + tool_args={"calendar": "Mock Calendar Name", "range": "today"}, + ) + now = dt_util.now() + with freeze_time(now): + response = await tool.async_call(hass, tool_input, llm_context) + + assert len(calls) == 1 + call = calls[0] + assert call.domain == calendar.DOMAIN + assert call.service == calendar.SERVICE_GET_EVENTS + assert call.data == { + "entity_id": [ENTITY_ID], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day(now) + timedelta(days=1), + } + + assert response == { + "success": True, + "result": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + "all_day": True, + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ], + } + + # The "week" range searches seven days out. + calls.clear() + tool_input.tool_args["range"] = "week" + with freeze_time(now): + await tool.async_call(hass, tool_input, llm_context) + assert call.domain == calendar.DOMAIN + assert calls[0].data["end_date_time"] == ( + dt_util.start_of_local_day(now) + timedelta(days=7) + ) + + +async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None: + """Test the tool reports when the requested calendar no longer matches.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context) + tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + + # Unexpose after the tool (and its calendar enum) was built, so the call-time + # match no longer finds the calendar. + async_expose_entity(hass, "conversation", ENTITY_ID, False) + response = await tool.async_call( + hass, + llm.ToolInput( + "calendar_get_events", {"calendar": "Mock Calendar Name", "range": "today"} + ), + llm_context, + ) + assert response == {"success": False, "error": "Calendar not found"} + + +async def test_calendar_get_events_tool_uses_aliases( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test exposed calendar aliases are offered as valid tool values.""" + entry = entity_registry.async_get_or_create( + "calendar", "test", "aliased", suggested_object_id="aliased" + ) + entity_registry.async_update_entity(entry.entity_id, aliases={"Family Calendar"}) + hass.states.async_set(entry.entity_id, "on") + async_expose_entity(hass, "conversation", entry.entity_id, True) + + result = await llm_component.async_get_tools(hass, _llm_context()) + tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + assert "Family Calendar" in tool.parameters.schema["calendar"].container From 631179dffe06573537ed9399e68bb9bd1a5e538a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 23:02:32 +0200 Subject: [PATCH 048/707] Skip compressing HTTP JSON responses below 1 KiB (#175560) Co-authored-by: Claude --- homeassistant/components/api/__init__.py | 7 +++-- homeassistant/helpers/http.py | 7 ++++- tests/components/api/test_init.py | 27 ++++++++++++++++ tests/helpers/test_http.py | 40 ++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/helpers/test_http.py diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index c6357ea4c3fd..39b74b3fe3b1 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -46,6 +46,7 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, recorder, template +from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE from homeassistant.helpers.json import json_dumps, json_fragment from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType @@ -223,12 +224,14 @@ class APIStatesView(HomeAssistantView): for state in hass.states.async_all() if entity_perm(state.entity_id, POLICY_READ) ) + body = b"".join((b"[", b",".join(states), b"]")) response = web.Response( - body=b"".join((b"[", b",".join(states), b"]")), + body=body, content_type=CONTENT_TYPE_JSON, zlib_executor_size=32768, ) - response.enable_compression() + if len(body) > MIN_COMPRESSED_RESPONSE_SIZE: + response.enable_compression() return response diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index f93009cbeb39..a380cec1ae31 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -27,6 +27,10 @@ from .json import find_paths_unserializable_data, json_bytes, json_dumps _LOGGER = logging.getLogger(__name__) +# Responses smaller than this fit within a single network packet, so +# compressing them wastes event-loop CPU without reducing round-trips. +MIN_COMPRESSED_RESPONSE_SIZE: Final = 1024 + type AllowCorsType = Callable[[AbstractRoute | AbstractResource], None] KEY_AUTHENTICATED: Final = "ha_authenticated" @@ -160,7 +164,8 @@ class HomeAssistantView: headers=headers, zlib_executor_size=32768, ) - response.enable_compression() + if len(msg) > MIN_COMPRESSED_RESPONSE_SIZE: + response.enable_compression() return response def json_message( diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index df95342e9bcf..7db0e2fc5679 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -51,6 +51,33 @@ async def test_api_list_state_entities( assert remote_data == local_data +@pytest.mark.parametrize( + ("entity_count", "expect_compression"), + [ + pytest.param(1, False, id="small-body-not-compressed"), + pytest.param(50, True, id="large-body-compressed"), + ], +) +async def test_api_states_compression_threshold( + hass: HomeAssistant, + mock_api_client: TestClient, + entity_count: int, + expect_compression: bool, +) -> None: + """Test that only state list responses above the size threshold are compressed.""" + for i in range(entity_count): + hass.states.async_set( + f"test.entity_{i}", "on", {"friendly_name": f"Entity {i}"} + ) + + resp = await mock_api_client.get( + const.URL_API_STATES, headers={"Accept-Encoding": "gzip, deflate"} + ) + + assert resp.status == HTTPStatus.OK + assert ("Content-Encoding" in resp.headers) is expect_compression + + async def test_api_get_state(hass: HomeAssistant, mock_api_client: TestClient) -> None: """Test if the debug interface allows us to get a state.""" hass.states.async_set("hello.world", "nice", {"attr": 1}) diff --git a/tests/helpers/test_http.py b/tests/helpers/test_http.py new file mode 100644 index 000000000000..81bcfc18bd89 --- /dev/null +++ b/tests/helpers/test_http.py @@ -0,0 +1,40 @@ +"""Tests for the HTTP helpers.""" + +from http import HTTPStatus + +from aiohttp import web +import pytest + +from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE, HomeAssistantView + +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("body_size", "expect_compression"), + [ + pytest.param(8, False, id="small-body-not-compressed"), + pytest.param( + MIN_COMPRESSED_RESPONSE_SIZE * 2, True, id="large-body-compressed" + ), + ], +) +@pytest.mark.usefixtures("socket_enabled") +async def test_json_response_compression_threshold( + aiohttp_client: ClientSessionGenerator, + body_size: int, + expect_compression: bool, +) -> None: + """Test HomeAssistantView.json only compresses bodies above the threshold.""" + + async def handler(request: web.Request) -> web.Response: + return HomeAssistantView.json({"data": "x" * body_size}) + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + resp = await client.get("/", headers={"Accept-Encoding": "gzip, deflate"}) + + assert resp.status == HTTPStatus.OK + assert ("Content-Encoding" in resp.headers) is expect_compression From 03cb60f209c1c6071ec0a68d13b8f62c06b35db7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Jul 2026 00:56:54 +0200 Subject: [PATCH 049/707] Bump aioamazondevices to 14.1.9 (#175563) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 330a41dccf27..f8268b06bba1 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==14.1.8"] + "requirements": ["aioamazondevices==14.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 07c3d7b2d673..3fdca3cdebac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==14.1.8 +aioamazondevices==14.1.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From ec071fe6c20a9b48d8b997d68a62fd70897234bc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 4 Jul 2026 00:57:57 +0200 Subject: [PATCH 050/707] Bump pylamarzocco to 2.4.2 (#175564) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 15f5f7b76a9a..0abd73db29a1 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.4.1"] + "requirements": ["pylamarzocco==2.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3fdca3cdebac..48bddfa31af6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2301,7 +2301,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.4.1 +pylamarzocco==2.4.2 # homeassistant.components.lastfm pylast==5.1.0 From 24774798f12bc5301af0ea68aa80354860a48459 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Jul 2026 02:39:51 +0200 Subject: [PATCH 051/707] Pass API ID to LLM tools platforms and let them opt out (#175572) Co-authored-by: Claude --- homeassistant/components/calendar/llm.py | 11 +++++-- homeassistant/components/climate/llm.py | 13 +++++--- homeassistant/components/fan/llm.py | 13 +++++--- homeassistant/components/intent_script/llm.py | 12 +++++-- homeassistant/components/lawn_mower/llm.py | 13 +++++--- homeassistant/components/light/llm.py | 13 +++++--- homeassistant/components/llm/__init__.py | 17 +++++++--- homeassistant/components/llm/llm.py | 4 ++- homeassistant/components/media_player/llm.py | 13 +++++--- homeassistant/components/todo/llm.py | 19 +++++++++--- homeassistant/components/vacuum/llm.py | 13 +++++--- tests/components/calendar/test_llm.py | 15 ++++++--- tests/components/climate/test_llm.py | 9 +++++- tests/components/fan/test_llm.py | 9 +++++- tests/components/intent_script/test_llm.py | 30 ++++++++++++++++-- tests/components/lawn_mower/test_llm.py | 9 +++++- tests/components/light/test_llm.py | 9 +++++- tests/components/llm/test_init.py | 31 ++++++++++++++----- tests/components/llm/test_tools.py | 2 +- tests/components/media_player/test_llm.py | 9 +++++- tests/components/todo/test_llm.py | 15 ++++++--- tests/components/vacuum/test_llm.py | 9 +++++- 22 files changed, 226 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py index 53fd1fc2d4cc..d92466377f58 100644 --- a/homeassistant/components/calendar/llm.py +++ b/homeassistant/components/calendar/llm.py @@ -10,7 +10,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, intent -from homeassistant.helpers.llm import LLMContext, Tool, ToolInput +from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType @@ -86,8 +86,13 @@ class CalendarGetEventsTool(Tool): @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return the calendar LLM tools when a calendar is exposed.""" + if api_id != LLM_API_ASSIST: + return None + entity_registry = er.async_get(hass) names: list[str] = [] for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): @@ -97,5 +102,5 @@ def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) if not names: - return LLMTools(tools=[]) + return None return LLMTools(tools=[CalendarGetEventsTool(names)]) diff --git a/homeassistant/components/climate/llm.py b/homeassistant/components/climate/llm.py index 2a8485494285..31a8e3f1e5c0 100644 --- a/homeassistant/components/climate/llm.py +++ b/homeassistant/components/climate/llm.py @@ -4,7 +4,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from .const import DOMAIN, INTENT_SET_TEMPERATURE @@ -13,16 +13,21 @@ LLM_INTENTS = (INTENT_SET_TEMPERATURE,) @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + if not llm_context.assistant: - return LLMTools(tools=[]) + return None if not any( async_should_expose(hass, llm_context.assistant, state.entity_id) for state in hass.states.async_all(DOMAIN) ): - return LLMTools(tools=[]) + return None tools: list[Tool] = [ IntentTool(handler.intent_type, handler) diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py index 1dedbdbf0f67..5296e634f099 100644 --- a/homeassistant/components/fan/llm.py +++ b/homeassistant/components/fan/llm.py @@ -4,7 +4,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from . import DOMAIN from .intent import INTENT_FAN_SET_SPEED @@ -14,16 +14,21 @@ LLM_INTENTS = (INTENT_FAN_SET_SPEED,) @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + if not llm_context.assistant: - return LLMTools(tools=[]) + return None if not any( async_should_expose(hass, llm_context.assistant, state.entity_id) for state in hass.states.async_all(DOMAIN) ): - return LLMTools(tools=[]) + return None tools: list[Tool] = [ IntentTool(handler.intent_type, handler) diff --git a/homeassistant/components/intent_script/llm.py b/homeassistant/components/intent_script/llm.py index da828eba3e9d..bc13382248da 100644 --- a/homeassistant/components/intent_script/llm.py +++ b/homeassistant/components/intent_script/llm.py @@ -6,14 +6,19 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from . import ScriptIntentHandler @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return an LLM tool for each configured intent script.""" + if api_id != LLM_API_ASSIST: + return None + handlers = [ handler for handler in intent.async_get(hass) @@ -31,6 +36,9 @@ def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: if handler.platforms is None or handler.platforms & exposed_domains ] + if not handlers: + return None + # Intent script names come from user configuration, so slugify them into # valid tool names. tools: list[Tool] = [ diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py index 2458a8df74e2..c51d5ecdcbde 100644 --- a/homeassistant/components/lawn_mower/llm.py +++ b/homeassistant/components/lawn_mower/llm.py @@ -4,7 +4,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from .const import DOMAIN from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING @@ -14,16 +14,21 @@ LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING) @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + if not llm_context.assistant: - return LLMTools(tools=[]) + return None if not any( async_should_expose(hass, llm_context.assistant, state.entity_id) for state in hass.states.async_all(DOMAIN) ): - return LLMTools(tools=[]) + return None tools: list[Tool] = [ IntentTool(handler.intent_type, handler) diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py index c9387aa82d71..5570245444f1 100644 --- a/homeassistant/components/light/llm.py +++ b/homeassistant/components/light/llm.py @@ -4,7 +4,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from .const import DOMAIN from .intent import INTENT_SET @@ -14,16 +14,21 @@ LLM_INTENTS = (INTENT_SET,) @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + if not llm_context.assistant: - return LLMTools(tools=[]) + return None if not any( async_should_expose(hass, llm_context.assistant, state.entity_id) for state in hass.states.async_all(DOMAIN) ): - return LLMTools(tools=[]) + return None tools: list[Tool] = [ IntentTool(handler.intent_type, handler) diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py index 0fdd1783e0e2..3f8d87eb2a05 100644 --- a/homeassistant/components/llm/__init__.py +++ b/homeassistant/components/llm/__init__.py @@ -40,8 +40,13 @@ class LLMToolsPlatformProtocol(Protocol): """Define the format that LLM tools platforms can have.""" @callback - def async_get_tools(self, hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: - """Return the integration's LLM tools for the given context.""" + def async_get_tools( + self, hass: HomeAssistant, llm_context: LLMContext, api_id: str + ) -> LLMTools | None: + """Return the integration's LLM tools for the given context and API. + + Return None when the integration has nothing for the given API. + """ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -60,7 +65,9 @@ def _process_llm_tools_platform( return platform -async def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +async def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools: """Return the tools and merged prompt from all integration platforms.""" platforms = await hass.data[DATA_PLATFORMS].async_get_platforms() @@ -69,10 +76,12 @@ async def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTo # Sort by domain so the tool and prompt order is independent of load order. for domain, platform in sorted(platforms.items()): try: - result = platform.async_get_tools(hass, llm_context) + result = platform.async_get_tools(hass, llm_context, api_id) except Exception: _LOGGER.exception("Error getting tools from LLM platform %s", domain) continue + if result is None: + continue tools.extend(result.tools) if result.prompt: prompts.append(result.prompt) diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py index 0e0ee54c65e3..c63f4098c245 100644 --- a/homeassistant/components/llm/llm.py +++ b/homeassistant/components/llm/llm.py @@ -38,6 +38,8 @@ class GetDateTimeTool(Tool): @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools: """Return the always-available LLM tools.""" return LLMTools(tools=[GetDateTimeTool()]) diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py index 5f94dc9932e5..aa6778835a64 100644 --- a/homeassistant/components/media_player/llm.py +++ b/homeassistant/components/media_player/llm.py @@ -4,7 +4,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from .const import ( DOMAIN, @@ -34,16 +34,21 @@ LLM_INTENTS = ( @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + if not llm_context.assistant: - return LLMTools(tools=[]) + return None if not any( async_should_expose(hass, llm_context.assistant, state.entity_id) for state in hass.states.async_all(DOMAIN) ): - return LLMTools(tools=[]) + return None tools: list[Tool] = [ IntentTool(handler.intent_type, handler) diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py index eaf365a288ab..ddfe261179c0 100644 --- a/homeassistant/components/todo/llm.py +++ b/homeassistant/components/todo/llm.py @@ -9,7 +9,13 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool, ToolInput +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolInput, +) from homeassistant.util.json import JsonObjectType from .const import DOMAIN, TodoServices @@ -90,10 +96,15 @@ class TodoGetItemsTool(Tool): @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return the todo LLM tools when a to-do list is exposed.""" + if api_id != LLM_API_ASSIST: + return None + if not llm_context.assistant: - return LLMTools(tools=[]) + return None entity_registry = er.async_get(hass) names: list[str] = [] @@ -104,7 +115,7 @@ def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) if not names: - return LLMTools(tools=[]) + return None tools: list[Tool] = [TodoGetItemsTool(names)] tools.extend( diff --git a/homeassistant/components/vacuum/llm.py b/homeassistant/components/vacuum/llm.py index da37732137f5..eb28c4a9ca25 100644 --- a/homeassistant/components/vacuum/llm.py +++ b/homeassistant/components/vacuum/llm.py @@ -4,7 +4,7 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import intent -from homeassistant.helpers.llm import IntentTool, LLMContext, Tool +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from .const import DOMAIN from .intent import ( @@ -22,16 +22,21 @@ LLM_INTENTS = ( @callback -def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + if not llm_context.assistant: - return LLMTools(tools=[]) + return None if not any( async_should_expose(hass, llm_context.assistant, state.entity_id) for state in hass.states.async_all(DOMAIN) ): - return LLMTools(tools=[]) + return None tools: list[Tool] = [ IntentTool(handler.intent_type, handler) diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py index 539dc54b7e95..056ff357d69e 100644 --- a/tests/components/calendar/test_llm.py +++ b/tests/components/calendar/test_llm.py @@ -6,6 +6,7 @@ from freezegun import freeze_time import pytest from homeassistant.components import calendar, llm as llm_component +from homeassistant.components.calendar import llm as calendar_llm from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.core import Context, HomeAssistant, SupportsResponse from homeassistant.helpers import entity_registry as er, llm @@ -42,14 +43,20 @@ def _llm_context() -> llm.LLMContext: async def test_get_tools_no_exposed_calendar(hass: HomeAssistant) -> None: """Test no calendar tool is offered when no calendar is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") assert "calendar_get_events" not in [tool.name for tool in result.tools] + assert calendar_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert calendar_llm.async_get_tools(hass, _llm_context(), "other") is None async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: """Test the calendar get events tool is exposed and works via the platform.""" llm_context = _llm_context() - result = await llm_component.async_get_tools(hass, llm_context) + result = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next( (tool for tool in result.tools if tool.name == "calendar_get_events"), None ) @@ -133,7 +140,7 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None: """Test the tool reports when the requested calendar no longer matches.""" llm_context = _llm_context() - result = await llm_component.async_get_tools(hass, llm_context) + result = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") # Unexpose after the tool (and its calendar enum) was built, so the call-time @@ -160,6 +167,6 @@ async def test_calendar_get_events_tool_uses_aliases( hass.states.async_set(entry.entity_id, "on") async_expose_entity(hass, "conversation", entry.entity_id, True) - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") assert "Family Calendar" in tool.parameters.schema["calendar"].container diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py index d702880b9c1f..85103a855768 100644 --- a/tests/components/climate/test_llm.py +++ b/tests/components/climate/test_llm.py @@ -3,6 +3,7 @@ import pytest from homeassistant.components import llm as llm_component +from homeassistant.components.climate import llm as climate_llm from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import llm @@ -36,7 +37,7 @@ def _llm_context() -> llm.LLMContext: async def _tool_names(hass: HomeAssistant) -> set[str]: """Return the names of the tools offered by the climate platform.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") return {tool.name for tool in result.tools} @@ -49,3 +50,9 @@ async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no climate entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) assert "HassClimateSetTemperature" not in await _tool_names(hass) + assert climate_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert climate_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/fan/test_llm.py b/tests/components/fan/test_llm.py index 9caacb703f80..92f95aad4651 100644 --- a/tests/components/fan/test_llm.py +++ b/tests/components/fan/test_llm.py @@ -3,6 +3,7 @@ import pytest from homeassistant.components import llm as llm_component +from homeassistant.components.fan import llm as fan_llm from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import llm @@ -36,7 +37,7 @@ def _llm_context() -> llm.LLMContext: async def _tool_names(hass: HomeAssistant) -> set[str]: """Return the names of the tools offered by the fan platform.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") return {tool.name for tool in result.tools} @@ -49,3 +50,9 @@ async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no fan entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) assert "HassFanSetSpeed" not in await _tool_names(hass) + assert fan_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert fan_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/intent_script/test_llm.py b/tests/components/intent_script/test_llm.py index a20803810508..c72d479cee38 100644 --- a/tests/components/intent_script/test_llm.py +++ b/tests/components/intent_script/test_llm.py @@ -1,11 +1,17 @@ """Tests for the intent_script LLM tools platform.""" +from unittest.mock import patch + import pytest from homeassistant.components import llm as llm_component from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent_script import ( + ScriptIntentHandler, + llm as intent_script_llm, +) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import llm +from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component LIGHT_ENTITY_ID = "light.kitchen" @@ -52,7 +58,7 @@ def _llm_context() -> llm.LLMContext: async def _tool_names(hass: HomeAssistant) -> set[str]: """Return the names of the tools offered by the intent_script platform.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") return {tool.name for tool in result.tools} @@ -71,3 +77,23 @@ async def test_intent_script_platform_filtered(hass: HomeAssistant) -> None: assert "LightAction" not in names # Unrestricted intent scripts stay exposed. assert "Tell_a_joke" in names + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert intent_script_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_no_tools_when_no_scripts_match(hass: HomeAssistant) -> None: + """Test None is returned when no intent scripts match the exposed domains.""" + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, False) + restricted_handlers = [ + handler + for handler in intent.async_get(hass) + if isinstance(handler, ScriptIntentHandler) and handler.platforms + ] + with patch( + "homeassistant.components.intent_script.llm.intent.async_get", + return_value=restricted_handlers, + ): + assert intent_script_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/lawn_mower/test_llm.py b/tests/components/lawn_mower/test_llm.py index 818f9f3295e3..204b07a4c51a 100644 --- a/tests/components/lawn_mower/test_llm.py +++ b/tests/components/lawn_mower/test_llm.py @@ -4,6 +4,7 @@ import pytest from homeassistant.components import llm as llm_component from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.lawn_mower import llm as lawn_mower_llm from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component @@ -37,7 +38,7 @@ def _llm_context() -> llm.LLMContext: async def _tool_names(hass: HomeAssistant) -> set[str]: """Return the names of the tools offered by the lawn_mower platform.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") return {tool.name for tool in result.tools} @@ -50,3 +51,9 @@ async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no lawn_mower entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) assert not INTENTS & await _tool_names(hass) + assert lawn_mower_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert lawn_mower_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/light/test_llm.py b/tests/components/light/test_llm.py index df57f50e2b77..d346ce0a4ee4 100644 --- a/tests/components/light/test_llm.py +++ b/tests/components/light/test_llm.py @@ -4,6 +4,7 @@ import pytest from homeassistant.components import llm as llm_component from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.light import llm as light_llm from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component @@ -36,7 +37,7 @@ def _llm_context() -> llm.LLMContext: async def _tool_names(hass: HomeAssistant) -> set[str]: """Return the names of the tools offered by the light platform.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") return {tool.name for tool in result.tools} @@ -49,3 +50,9 @@ async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no light entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) assert "HassLightSet" not in await _tool_names(hass) + assert light_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert light_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/llm/test_init.py b/tests/components/llm/test_init.py index 753b6d68c05d..561c2cc04e13 100644 --- a/tests/components/llm/test_init.py +++ b/tests/components/llm/test_init.py @@ -44,8 +44,8 @@ def llm_context() -> llm.LLMContext: def _mock_tools_platform( - hass: HomeAssistant, domain: str, tools: LLMTools | Exception -) -> None: + hass: HomeAssistant, domain: str, tools: LLMTools | Exception | None +) -> Mock: """Register a mock /llm.py platform returning the given tools.""" if isinstance(tools, Exception): async_get_tools = Mock(side_effect=tools) @@ -53,6 +53,7 @@ def _mock_tools_platform( async_get_tools = Mock(return_value=tools) hass.config.components.add(domain) mock_platform(hass, f"{domain}.llm", Mock(async_get_tools=async_get_tools)) + return async_get_tools async def test_setup(hass: HomeAssistant) -> None: @@ -64,16 +65,17 @@ async def test_setup(hass: HomeAssistant) -> None: async def test_get_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: """Test that tools from an integration platform are returned.""" tool = _StubTool("my_tool") - _mock_tools_platform( + platform_get_tools = _mock_tools_platform( hass, "test", LLMTools(tools=[tool], prompt="use my_tool wisely") ) assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) + result = await async_get_tools(hass, llm_context, "assist") # The llm integration also exposes its own GetDateTime tool (domain "llm"). assert [tool.name for tool in result.tools] == ["GetDateTime", "my_tool"] assert result.prompt == "use my_tool wisely" + platform_get_tools.assert_called_once_with(hass, llm_context, "assist") async def test_get_tools_empty( @@ -82,7 +84,7 @@ async def test_get_tools_empty( """Test that only the llm integration's own tools are returned by default.""" assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) + result = await async_get_tools(hass, llm_context, "assist") assert [tool.name for tool in result.tools] == ["GetDateTime"] assert result.prompt is None @@ -99,11 +101,26 @@ async def test_get_tools_merges_sorted( assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) + result = await async_get_tools(hass, llm_context, "assist") assert [tool.name for tool in result.tools] == ["GetDateTime", "tool_a", "tool_b"] assert result.prompt == "prompt a\nprompt b" +async def test_get_tools_skips_none_platform( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test that a platform returning None for the API is skipped.""" + tool = _StubTool("good_tool") + _mock_tools_platform(hass, "test_none", None) + _mock_tools_platform(hass, "test_good", LLMTools(tools=[tool])) + + assert await async_setup_component(hass, "llm", {}) + + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] + assert result.prompt is None + + async def test_get_tools_isolates_failing_platform( hass: HomeAssistant, llm_context: llm.LLMContext, @@ -116,7 +133,7 @@ async def test_get_tools_isolates_failing_platform( assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) + result = await async_get_tools(hass, llm_context, "assist") assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] assert result.prompt == "prompt" assert "Error getting tools from LLM platform test_bad" in caplog.text diff --git a/tests/components/llm/test_tools.py b/tests/components/llm/test_tools.py index 130726ba3d24..cb0d31e31104 100644 --- a/tests/components/llm/test_tools.py +++ b/tests/components/llm/test_tools.py @@ -32,7 +32,7 @@ def _llm_context() -> llm.LLMContext: async def test_get_datetime_tool(hass: HomeAssistant) -> None: """Test the GetDateTime tool is always offered and returns the current time.""" llm_context = _llm_context() - result = await llm_component.async_get_tools(hass, llm_context) + result = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next((tool for tool in result.tools if tool.name == "GetDateTime"), None) assert tool is not None diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py index 869692e8b5ec..7ca247a63af0 100644 --- a/tests/components/media_player/test_llm.py +++ b/tests/components/media_player/test_llm.py @@ -4,6 +4,7 @@ import pytest from homeassistant.components import llm as llm_component from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.media_player import llm as media_player_llm from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component @@ -47,7 +48,7 @@ def _llm_context() -> llm.LLMContext: async def _tool_names(hass: HomeAssistant) -> set[str]: """Return the names of the tools offered by the media_player platform.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") return {tool.name for tool in result.tools} @@ -60,3 +61,9 @@ async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no media_player entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) assert not INTENTS & await _tool_names(hass) + assert media_player_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert media_player_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py index dccaeb880b50..c409e0e0288b 100644 --- a/tests/components/todo/test_llm.py +++ b/tests/components/todo/test_llm.py @@ -4,6 +4,7 @@ import pytest from homeassistant.components import llm as llm_component, todo from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.todo import llm as todo_llm from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import config_validation as cv, llm from homeassistant.setup import async_setup_component @@ -39,14 +40,20 @@ def _llm_context() -> llm.LLMContext: async def test_get_tools_no_exposed_todo(hass: HomeAssistant) -> None: """Test no todo tool is offered when no to-do list is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") assert "todo_get_items" not in [tool.name for tool in result.tools] + assert todo_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert todo_llm.async_get_tools(hass, _llm_context(), "other") is None async def test_todo_get_items_tool(hass: HomeAssistant) -> None: """Test the todo get items tool is exposed and works via the platform.""" llm_context = _llm_context() - result = await llm_component.async_get_tools(hass, llm_context) + result = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next((tool for tool in result.tools if tool.name == "todo_get_items"), None) assert tool is not None assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] @@ -91,7 +98,7 @@ async def test_todo_get_items_status_filter( ) -> None: """Test the status filter is translated into the service call.""" llm_context = _llm_context() - result = await llm_component.async_get_tools(hass, llm_context) + result = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next(tool for tool in result.tools if tool.name == "todo_get_items") calls = async_mock_service( @@ -113,7 +120,7 @@ async def test_todo_get_items_status_filter( async def test_todo_list_intents_exposed(hass: HomeAssistant) -> None: """Test the todo list intents are exposed as tools when a list is exposed.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") names = {tool.name for tool in result.tools} assert "HassListAddItem" in names assert "HassListCompleteItem" in names diff --git a/tests/components/vacuum/test_llm.py b/tests/components/vacuum/test_llm.py index ec3cc8e7c8a9..441b1d8eb6df 100644 --- a/tests/components/vacuum/test_llm.py +++ b/tests/components/vacuum/test_llm.py @@ -4,6 +4,7 @@ import pytest from homeassistant.components import llm as llm_component from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.vacuum import llm as vacuum_llm from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component @@ -37,7 +38,7 @@ def _llm_context() -> llm.LLMContext: async def _tool_names(hass: HomeAssistant) -> set[str]: """Return the names of the tools offered by the vacuum platform.""" - result = await llm_component.async_get_tools(hass, _llm_context()) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") return {tool.name for tool in result.tools} @@ -50,3 +51,9 @@ async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: """Test the intent tool is hidden when no vacuum entity is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) assert not INTENTS & await _tool_names(hass) + assert vacuum_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert vacuum_llm.async_get_tools(hass, _llm_context(), "other") is None From 3aa56eab27623c0327747d4e80be17aaf5991823 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 3 Jul 2026 23:20:10 -0700 Subject: [PATCH 052/707] Add llama_cpp conversation integration (#175580) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/llama_cpp/__init__.py | 60 ++ homeassistant/components/llama_cpp/api.py | 192 ++++++ .../components/llama_cpp/config_flow.py | 383 ++++++++++++ homeassistant/components/llama_cpp/const.py | 30 + .../components/llama_cpp/conversation.py | 83 +++ homeassistant/components/llama_cpp/entity.py | 457 ++++++++++++++ .../components/llama_cpp/manifest.json | 13 + .../components/llama_cpp/quality_scale.yaml | 104 ++++ .../components/llama_cpp/strings.json | 98 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + tests/components/llama_cpp/__init__.py | 1 + tests/components/llama_cpp/conftest.py | 167 +++++ .../snapshots/test_conversation.ambr | 92 +++ .../components/llama_cpp/test_config_flow.py | 585 ++++++++++++++++++ .../components/llama_cpp/test_conversation.py | 572 +++++++++++++++++ tests/components/llama_cpp/test_init.py | 68 ++ 21 files changed, 2926 insertions(+) create mode 100644 homeassistant/components/llama_cpp/__init__.py create mode 100644 homeassistant/components/llama_cpp/api.py create mode 100644 homeassistant/components/llama_cpp/config_flow.py create mode 100644 homeassistant/components/llama_cpp/const.py create mode 100644 homeassistant/components/llama_cpp/conversation.py create mode 100644 homeassistant/components/llama_cpp/entity.py create mode 100644 homeassistant/components/llama_cpp/manifest.json create mode 100644 homeassistant/components/llama_cpp/quality_scale.yaml create mode 100644 homeassistant/components/llama_cpp/strings.json create mode 100644 tests/components/llama_cpp/__init__.py create mode 100644 tests/components/llama_cpp/conftest.py create mode 100644 tests/components/llama_cpp/snapshots/test_conversation.ambr create mode 100644 tests/components/llama_cpp/test_config_flow.py create mode 100644 tests/components/llama_cpp/test_conversation.py create mode 100644 tests/components/llama_cpp/test_init.py diff --git a/.strict-typing b/.strict-typing index 66075b93743b..97a0df52c58d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -347,6 +347,7 @@ homeassistant.components.light.* homeassistant.components.linkplay.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* +homeassistant.components.llama_cpp.* homeassistant.components.local_ip.* homeassistant.components.local_todo.* homeassistant.components.lock.* diff --git a/CODEOWNERS b/CODEOWNERS index 409c3e54bdfa..af40ccba5847 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1026,6 +1026,8 @@ CLAUDE.md @home-assistant/core /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet /tests/components/livisi/ @StefanIacobLivisi @planbnet +/homeassistant/components/llama_cpp/ @allenporter +/tests/components/llama_cpp/ @allenporter /homeassistant/components/llm/ @home-assistant/core /tests/components/llm/ @home-assistant/core /homeassistant/components/local_calendar/ @allenporter diff --git a/homeassistant/components/llama_cpp/__init__.py b/homeassistant/components/llama_cpp/__init__.py new file mode 100644 index 000000000000..0153fc32c2d0 --- /dev/null +++ b/homeassistant/components/llama_cpp/__init__.py @@ -0,0 +1,60 @@ +"""The llama.cpp integration.""" + +import logging + +import openai + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) + +from .api import async_create_client, async_list_models + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = (Platform.CONVERSATION,) + +type LlamaCppConfigEntry = ConfigEntry[openai.AsyncOpenAI] + + +async def async_setup_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool: + """Set up llama.cpp from a config entry.""" + client = await async_create_client(hass, entry.data) + + # Validate the connection by listing models + try: + await async_list_models(client) + except HomeAssistantError as err: + if err.translation_key == "invalid_auth": + raise ConfigEntryAuthFailed( + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) from err + raise ConfigEntryNotReady( + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool: + """Unload llama.cpp.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_options(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/llama_cpp/api.py b/homeassistant/components/llama_cpp/api.py new file mode 100644 index 000000000000..bf2c7bfa3959 --- /dev/null +++ b/homeassistant/components/llama_cpp/api.py @@ -0,0 +1,192 @@ +"""API client helper for llama.cpp integration. + +This module contains thin wrappers around the OpenAI completions APIs used +to simplify Home Assistant integration and configuration. It handles client +setup, model validation, and API error handling. +""" + +from collections.abc import Generator, Mapping +from contextlib import contextmanager +import logging +from typing import Any, cast + +import openai +from openai._streaming import AsyncStream +from openai.types.chat import ( + ChatCompletionChunk, + ChatCompletionMessageParam, + ChatCompletionToolParam, +) + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.httpx_client import get_async_client + +from .const import ( + CONF_BASE_URL, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DOMAIN, + RECOMMENDED_CHAT_MODELS, +) + +_LOGGER = logging.getLogger(__name__) + + +# Simple prompt to test model basic chat completion capability. We send tools +# to ensure the model and server correctly supports tool calling. We set a +# minimal max_tokens to consume few resources. +_TEST_MESSAGES: list[ChatCompletionMessageParam] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the capital of France?"}, +] +_TEST_TOOLS: list[ChatCompletionToolParam] = [ + { + "type": "function", + "function": { + "name": "test_function", + "description": "Test function.", + "parameters": {"type": "object", "properties": {}}, + }, + } +] +_TEST_MAX_TOKENS = 3 + + +async def async_create_client( + hass: HomeAssistant, config_entry_data: Mapping[str, Any] +) -> openai.AsyncOpenAI: + """Create a new OpenAI client.""" + api_key = config_entry_data.get(CONF_API_KEY) or DEFAULT_API_KEY + client = openai.AsyncOpenAI( + api_key=api_key, + base_url=config_entry_data[CONF_BASE_URL], + http_client=get_async_client(hass), + ) + # Cache current platform data which gets added to each request + # (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + return client + + +async def async_list_models(client: openai.AsyncOpenAI) -> list[str]: + """Return a list of models supported by the client.""" + with api_error_handler(): + page = await client.with_options(timeout=10.0).models.list() + return [model.id async for model in page] + + +async def async_validate_completions( + client: openai.AsyncOpenAI, + model: str, + stream: bool = False, +) -> None: + """Validate that we can speak to the model over the completions API.""" + with api_error_handler(): + result = await client.chat.completions.create( + model=model, + messages=_TEST_MESSAGES, + tools=_TEST_TOOLS, + max_tokens=_TEST_MAX_TOKENS, + stream=stream, + ) + + if stream: + stream_result = cast(AsyncStream[ChatCompletionChunk], result) + async for event in stream_result: + if not event.choices: + continue + if event.choices[0].finish_reason is not None: + continue + + +def recommended_model(models: list[str] | None) -> str: + """Return the selected model from user input.""" + if not models: + return DEFAULT_MODEL + for model in RECOMMENDED_CHAT_MODELS: + if model in models: + return model + return models[0] + + +def model_name_to_title(model_id: str) -> str: + """Convert a model ID into a human-readable title (inverse slugification). + + Examples: + - "deepseek-v4-flash" -> "Deepseek V4 Flash" + - "gpt-4" -> "Gpt 4" + - "llama-3.2-3b-instruct" -> "Llama 3.2 3b Instruct" + - "anthropic/claude-fable-5" -> "Anthropic Claude Fable 5" + """ + words = model_id.replace("-", " ").replace("_", " ").replace("/", " ").split() + return " ".join(word.capitalize() for word in words) + + +def _extract_error_message(err: openai.APIStatusError) -> str: + """Extract a clean error message from an APIStatusError response or message.""" + error_message = "" + if err.response is not None: + try: + json_data = err.response.json() + if isinstance(json_data, dict) and "error" in json_data: + error_message = json_data["error"].get("message") or "" + except ValueError: + pass + return error_message or err.message or str(err) + + +@contextmanager +def api_error_handler() -> Generator[None]: + """Context manager to handle API errors and translate them to HomeAssistantErrors.""" + try: + yield + except openai.APITimeoutError as err: + _LOGGER.error("Timeout talking to API: %s", err) + error_message = err.message or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={"message": error_message}, + ) from err + except openai.APIConnectionError as err: + _LOGGER.error("Connection error talking to API: %s", err) + error_message = err.message or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"message": error_message}, + ) from err + except openai.AuthenticationError as err: + _LOGGER.error("Authentication error talking to API: %s", err) + error_message = _extract_error_message(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"message": error_message}, + ) from err + except openai.APIStatusError as err: + _LOGGER.error("Status error talking to API: %s", err) + error_message = _extract_error_message(err) + + if err.status_code == 402: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="quota_exceeded", + translation_placeholders={"message": error_message}, + ) from err + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": error_message}, + ) from err + except openai.OpenAIError as err: + _LOGGER.error("Generic error talking to API: %s", err) + error_message = getattr(err, "message", None) or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": error_message}, + ) from err diff --git a/homeassistant/components/llama_cpp/config_flow.py b/homeassistant/components/llama_cpp/config_flow.py new file mode 100644 index 000000000000..8c7e42c29ed5 --- /dev/null +++ b/homeassistant/components/llama_cpp/config_flow.py @@ -0,0 +1,383 @@ +"""Config flow for llama.cpp integration.""" + +import logging +from typing import Any, cast, override + +import openai +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .api import ( + async_create_client, + async_list_models, + async_validate_completions, + model_name_to_title, + recommended_model, +) +from .const import ( + CONF_BASE_URL, + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_RECOMMENDED, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_BASE_URL, + DOMAIN, + LOGGER, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str, + vol.Optional(CONF_API_KEY): str, + } +) + + +class LlamaCppConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for llama.cpp.""" + + VERSION = 1 + + data: dict[str, Any] | None = None + client: openai.AsyncOpenAI | None = None + models: list[str] | None = None + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + try: + self.client = await async_create_client(self.hass, user_input) + self.models = await async_list_models(self.client) + except HomeAssistantError as err: + LOGGER.error("Connection validation failed: %s", err) + errors["base"] = err.translation_key or "unknown" + except Exception: # pylint: disable=broad-except # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = user_input + return await self.async_step_model() + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_model( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting a model.""" + assert self.client is not None + assert self.models is not None + assert self.data is not None + errors = {} + if user_input is not None: + model = user_input[CONF_CHAT_MODEL] + try: + await async_validate_completions( + self.client, + model=model, + stream=False, + ) + except HomeAssistantError as err: + LOGGER.error("Model completion validation failed: %s", err) + errors["base"] = err.translation_key or "unknown" + else: + stream_support = True + try: + await async_validate_completions( + self.client, + model=model, + stream=True, + ) + except HomeAssistantError: + stream_support = False + + base_options = { + **user_input, + } + return self.async_create_entry( + title=self.data[CONF_BASE_URL], + data={ + **self.data, + CONF_STREAMING: stream_support, + }, + subentries=[ + { + "subentry_type": "conversation", + "data": { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + **base_options, + }, + "title": model_name_to_title(model), + "unique_id": None, + }, + ], + ) + + return self.async_show_form( + step_id="model", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional( + CONF_CHAT_MODEL, + ): SelectSelector( + SelectSelectorConfig( + options=self.models, + translation_key=CONF_CHAT_MODEL, + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ), + ), + } + ), + { + CONF_CHAT_MODEL: (user_input or {}).get( + CONF_CHAT_MODEL, recommended_model(self.models) + ), + }, + ), + errors=errors, + ) + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return { + "conversation": ConversationSubentryFlowHandler, + } + + +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" + + last_rendered_recommended = False + options: dict[str, Any] | None = None + models: list[str] | None = None + + @property + def _openai_client(self) -> openai.AsyncOpenAI: + """Return the OpenAI client.""" + return cast(openai.AsyncOpenAI, self._get_entry().runtime_data) + + async def _get_models(self) -> list[str] | None: + """Return the list of models.""" + if self.models is None: + self.models = await async_list_models(self._openai_client) + return self.models + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + try: + models = await self._get_models() + except HomeAssistantError: + return self.async_abort(reason="cannot_connect") + self.options = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_CHAT_MODEL: recommended_model(models), + } + self.last_rendered_recommended = cast( + bool, self.options.get(CONF_RECOMMENDED, False) + ) + return await self.async_step_init() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a subentry.""" + return await self.async_step_init() + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage initial options.""" + # abort if entry is not loaded + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if self.options is None: + self.options = self._get_reconfigure_subentry().data.copy() + self.last_rendered_recommended = cast( + bool, self.options.get(CONF_RECOMMENDED, False) + ) + + try: + models = await self._get_models() + except HomeAssistantError: + return self.async_abort(reason="cannot_connect") + + options = self.options + + if user_input is not None: + model = user_input[CONF_CHAT_MODEL] + try: + await async_validate_completions( + self._openai_client, + model=model, + stream=self._get_entry().data.get(CONF_STREAMING, False), + ) + except HomeAssistantError as err: + LOGGER.error("Model completion validation failed: %s", err) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + llama_cpp_config_option_schema(self.hass, options, models) + ), + user_input, + ), + errors={"base": err.translation_key or "unknown"}, + ) + + if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if self.source == "user": + return self.async_create_entry( + title=model_name_to_title(user_input[CONF_CHAT_MODEL]), + data=user_input, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + title=model_name_to_title(user_input[CONF_CHAT_MODEL]), + ) + + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API, []), + } + + schema = llama_cpp_config_option_schema(self.hass, options, models) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema), options + ), + ) + + +def llama_cpp_config_option_schema( + hass: HomeAssistant, + options: dict[str, Any], + models: list[str] | None = None, +) -> dict: + """Return a schema for llama.cpp completion options.""" + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ] + LOGGER.debug("Available LLM APIs: %s", hass_apis) + + schema: dict[vol.Required | vol.Optional, Any] = {} + + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + } + ) + schema.update( + { + vol.Optional( + CONF_CHAT_MODEL, + description={"suggested_value": options.get(CONF_CHAT_MODEL)}, + default=options.get(CONF_CHAT_MODEL, recommended_model(models)), + ): SelectSelector( + SelectSelectorConfig( + options=models or [], + translation_key=CONF_CHAT_MODEL, + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ), + ), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) + + if options.get(CONF_RECOMMENDED): + return schema + + schema.update( + { + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options.get(CONF_TOP_P)}, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options.get(CONF_TEMPERATURE)}, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + } + ) + return schema diff --git a/homeassistant/components/llama_cpp/const.py b/homeassistant/components/llama_cpp/const.py new file mode 100644 index 000000000000..401d2a99abc3 --- /dev/null +++ b/homeassistant/components/llama_cpp/const.py @@ -0,0 +1,30 @@ +"""Constants for the llama.cpp integration.""" + +import logging + +DOMAIN = "llama_cpp" +LOGGER = logging.getLogger(__package__) + +DEFAULT_CONVERSATION_NAME = "llama.cpp Conversation" + +CONF_CHAT_MODEL = "chat_model" +CONF_MAX_TOKENS = "max_tokens" +CONF_TEMPERATURE = "temperature" +CONF_TOP_P = "top_p" +CONF_BASE_URL = "base_url" +CONF_RECOMMENDED = "recommended" +CONF_STREAMING = "streaming" + +# Some servers set placeholder model names which we can use as a default +DEFAULT_MODEL = "gpt-3.5-turbo" +RECOMMENDED_CHAT_MODELS = [ + DEFAULT_MODEL, + "gpt-4", + "local-model", +] +RECOMMENDED_MAX_TOKENS = 3000 +RECOMMENDED_TEMPERATURE = 0.7 +RECOMMENDED_TOP_P = 1.0 + +DEFAULT_BASE_URL = "http://localhost:8080/v1" +DEFAULT_API_KEY = "sk-0000000000000000000" diff --git a/homeassistant/components/llama_cpp/conversation.py b/homeassistant/components/llama_cpp/conversation.py new file mode 100644 index 000000000000..44ff4c07d7fe --- /dev/null +++ b/homeassistant/components/llama_cpp/conversation.py @@ -0,0 +1,83 @@ +"""Conversation support for llama.cpp.""" + +from typing import Literal, override + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LlamaCppConfigEntry +from .const import DOMAIN +from .entity import LlamaCppBaseLLMEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LlamaCppConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry in config_entry.subentries.values(): + async_add_entities( + [LlamaCppConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class LlamaCppConversationEntity( + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + LlamaCppBaseLLMEntity, +): + """llama.cpp conversation agent.""" + + def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + @override + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + @override + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + conversation.async_set_agent(self.hass, self.entry, self) + + @override + async def async_will_remove_from_hass(self) -> None: + """When entity will be removed from Home Assistant.""" + conversation.async_unset_agent(self.hass, self.entry) + await super().async_will_remove_from_hass() + + @override + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process a sentence.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/llama_cpp/entity.py b/homeassistant/components/llama_cpp/entity.py new file mode 100644 index 000000000000..605c9ffb9793 --- /dev/null +++ b/homeassistant/components/llama_cpp/entity.py @@ -0,0 +1,457 @@ +"""Base entity for llama.cpp Conversation.""" + +import base64 +from collections.abc import AsyncGenerator, Callable +import json +import logging +import mimetypes +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, cast + +from openai import AsyncOpenAI +from openai._streaming import AsyncStream +from openai._types import Omit +from openai.types.chat import ( + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPartParam, + ChatCompletionContentPartTextParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema +import voluptuous as vol +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from .api import api_error_handler +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_MODEL, + DOMAIN, + LOGGER, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, +) + +if TYPE_CHECKING: + from . import LlamaCppConfigEntry + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_structured_output( + name: str, structure: vol.Schema, llm_api: llm.APIInstance | None +) -> ResponseFormatJSONSchema: + """Format structured output specification.""" + schema = convert( + structure, custom_serializer=llm_api.custom_serializer if llm_api else None + ) + return ResponseFormatJSONSchema( + type="json_schema", + json_schema={ + "name": name, + "strict": True, + "schema": cast(dict[str, object], schema), + }, + ) + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + _LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to OpenAI API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="json_parse_error", + translation_placeholders={"message": str(err)}, + ) from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenAI API message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if isinstance(tool_call, ChatCompletionMessageFunctionToolCall) + ] + yield data + + +def _convert_content_to_param( + content: conversation.Content, +) -> ChatCompletionMessageParam: + """Convert any native chat message for this agent to the native format.""" + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if not isinstance(content, conversation.AssistantContent) or not content.tool_calls: + if isinstance(content, conversation.SystemContent): + return ChatCompletionSystemMessageParam( + role="system", + content=content.content or "", + ) + return cast( + ChatCompletionMessageParam, + {"role": content.role, "content": content.content or ""}, + ) + + return ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + tool_calls=[ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + type="function", + ) + for tool_call in content.tool_calls + ], + ) + + +async def _transform_stream( + result: AsyncStream[ChatCompletionChunk], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform an OpenAI delta stream into HA format.""" + current_tool_call: dict[str, Any] | None = None + yielded_role = False + + async for chunk in result: + LOGGER.debug("Received chunk: %s", chunk) + if not chunk.choices: + continue + choice = chunk.choices[0] + + if choice.finish_reason: + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=_decode_tool_arguments( + current_tool_call["tool_args"] + ) + if current_tool_call["tool_args"] + else {}, + ) + ] + } + break + + delta = choice.delta + + if current_tool_call is None and not delta.tool_calls: + yield_dict: conversation.AssistantContentDeltaDict = {} + if not yielded_role and delta.role == "assistant": + yield_dict["role"] = "assistant" + yielded_role = True + if delta.content is not None: + yield_dict["content"] = delta.content + if yield_dict: + yield yield_dict + continue + + if ( + not delta.tool_calls + or not (delta_tool_call := delta.tool_calls[0]) + or not delta_tool_call.function + ): + continue + + if current_tool_call and delta_tool_call.index == current_tool_call["index"]: + current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" + continue + + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=_decode_tool_arguments( + current_tool_call["tool_args"] + ), + ) + ] + } + + current_tool_call = { + "index": delta_tool_call.index, + "id": delta_tool_call.id, + "tool_name": delta_tool_call.function.name, + "tool_args": delta_tool_call.function.arguments or "", + } + + +class LlamaCppBaseLLMEntity(Entity): + """llama.cpp base LLM entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: LlamaCppConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="llama.cpp", + model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT_MODEL), + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + model: str = options.get(CONF_CHAT_MODEL, DEFAULT_MODEL) + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + response_format: ResponseFormatJSONSchema | Omit = Omit() + if structure and structure_name: + response_format = _format_structured_output( + structure_name, structure, chat_log.llm_api + ) + + last_content = chat_log.content[-1] + if ( + isinstance(last_content, conversation.UserContent) + and last_content.attachments + ): + files = await async_prepare_files_for_prompt( + self.hass, + [a.path for a in last_content.attachments], + ) + for i in range(len(messages) - 1, -1, -1): + if messages[i]["role"] == "user": + user_msg = cast(ChatCompletionUserMessageParam, messages[i]) + current_content = user_msg.get("content") + if isinstance(current_content, str): + user_msg["content"] = [ + ChatCompletionContentPartTextParam( + type="text", text=current_content + ), + *files, + ] + break + + client: AsyncOpenAI = self.entry.runtime_data + streaming = bool( + self.entry.data.get(CONF_STREAMING, options.get(CONF_STREAMING, False)) + ) + + for _iteration in range(MAX_TOOL_ITERATIONS): + with api_error_handler(): + result = await client.chat.completions.create( + messages=messages, + model=model, + tools=tools or Omit(), + response_format=response_format, + max_tokens=cast( + int, options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS) + ), + top_p=cast(float, options.get(CONF_TOP_P, RECOMMENDED_TOP_P)), + temperature=cast( + float, options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE) + ), + user=chat_log.conversation_id, + stream=cast(Any, streaming), + ) + + convert_message: Callable[[Any], Any] + async_generator: AsyncGenerator[conversation.AssistantContentDeltaDict] + if streaming: + convert_message = _convert_content_to_param + async_generator = _transform_stream( + cast(AsyncStream[ChatCompletionChunk], result) + ) + else: + convert_message = _convert_content_to_chat_message + async_generator = _transform_response( + cast(ChatCompletion, result).choices[0].message + ) + + messages.extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, async_generator + ) + if (msg := convert_message(content)) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + +async def async_prepare_files_for_prompt( + hass: HomeAssistant, files: list[Path] +) -> list[ChatCompletionContentPartParam]: + """Prepare files for OpenAI-compatible API. + + Caller needs to ensure that the files are allowed. + """ + + def guess_file_type(file_path: Path) -> tuple[str | None, str | None]: + """Guess the file type based on the file extension.""" + return mimetypes.guess_type(str(file_path)) + + def append_files_to_content() -> list[ChatCompletionContentPartParam]: + content: list[ChatCompletionContentPartParam] = [] + + for file_path in files: + if not file_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_not_found", + translation_placeholders={"file_path": str(file_path)}, + ) + + mime_type, _ = guess_file_type(file_path) + + if not mime_type or not mime_type.startswith(("image/", "application/pdf")): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_file_type", + translation_placeholders={"file_path": str(file_path)}, + ) + + base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8") + + if mime_type.startswith("image/"): + content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{base64_file}", + "detail": "auto", + }, + } + ) + elif mime_type.startswith("application/pdf"): + content.append( + { + "type": "text", + "text": f"[File: {file_path.name}]\nContent: {base64_file}", + } + ) + + return content + + return await hass.async_add_executor_job(append_files_to_content) diff --git a/homeassistant/components/llama_cpp/manifest.json b/homeassistant/components/llama_cpp/manifest.json new file mode 100644 index 000000000000..1285be7afabf --- /dev/null +++ b/homeassistant/components/llama_cpp/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "llama_cpp", + "name": "llama.cpp", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/llama_cpp", + "integration_type": "service", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.21.0"] +} diff --git a/homeassistant/components/llama_cpp/quality_scale.yaml b/homeassistant/components/llama_cpp/quality_scale.yaml new file mode 100644 index 000000000000..672cef1fdef1 --- /dev/null +++ b/homeassistant/components/llama_cpp/quality_scale.yaml @@ -0,0 +1,104 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions are registered by this integration. + appropriate-polling: + status: exempt + comment: The integration does not poll and is push-based. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions are registered by this integration. + docs-conditions: + status: exempt + comment: No custom conditions are supported by this integration. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: No custom triggers are supported by this integration. + entity-event-setup: + status: exempt + comment: No event entities or helper events are supported by this integration. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions are registered by this integration. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Conversation entities do not have an unavailable state. + integration-owner: done + log-when-unavailable: + status: exempt + comment: Conversation entities do not have an unavailable state. + parallel-updates: + status: exempt + comment: No periodic updates are performed by this integration. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration does not support discovery. + discovery: + status: exempt + comment: The integration does not support discovery. + docs-data-update: + status: exempt + comment: No periodic data updates are performed by this integration. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration does not support physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No physical devices are supported. + entity-category: + status: exempt + comment: Conversation entity does not require an entity category. + entity-device-class: + status: exempt + comment: Conversation entity does not require a device class. + entity-disabled-by-default: + status: exempt + comment: Conversation entity should be enabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: No icons are defined for this integration. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues are defined for this integration. + stale-devices: + status: exempt + comment: No physical devices are supported. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/llama_cpp/strings.json b/homeassistant/components/llama_cpp/strings.json new file mode 100644 index 000000000000..d298773a914e --- /dev/null +++ b/homeassistant/components/llama_cpp/strings.json @@ -0,0 +1,98 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "api_error": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "quota_exceeded": "Your account or API key has insufficient credits.", + "timeout": "Connection timed out.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "model": { + "data": { + "chat_model": "[%key:common::generic::model%]" + }, + "data_description": { + "chat_model": "Select the model to use." + } + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "base_url": "URL" + }, + "data_description": { + "api_key": "API key for the server (optional).", + "base_url": "Base URL of your running OpenAI-compatible server (e.g. http://localhost:8080/v1)." + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "chat_model": "[%key:common::generic::model%]", + "llm_hass_api": "Control Home Assistant", + "max_tokens": "Maximum tokens to return in response", + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "recommended": "Recommended model settings", + "temperature": "Temperature", + "top_p": "Top P" + }, + "data_description": { + "chat_model": "Select the model to use.", + "llm_hass_api": "Select the level of control over Home Assistant.", + "max_tokens": "Select the maximum number of tokens to return.", + "prompt": "Instruct how the LLM should respond. This can be a template.", + "recommended": "Select whether to use recommended model settings.", + "temperature": "Select the temperature for response variability.", + "top_p": "Select the top P value for response diversity." + } + } + } + } + }, + "exceptions": { + "api_error": { + "message": "API error: {message}." + }, + "cannot_connect": { + "message": "Cannot connect to the server: {message}." + }, + "file_not_found": { + "message": "File does not exist: {file_path}." + }, + "invalid_auth": { + "message": "Invalid authentication: {message}." + }, + "json_parse_error": { + "message": "Unexpected tool argument response: {message}." + }, + "quota_exceeded": { + "message": "Your account or API key has insufficient credits: {message}." + }, + "timeout": { + "message": "Connection timed out: {message}." + }, + "unsupported_file_type": { + "message": "Only images and PDF are supported by the OpenAI API, {file_path} is not an image file or PDF." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 789334a9d9fd..23026bbda1bd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -424,6 +424,7 @@ FLOWS = { "litejet", "litterrobot", "livisi", + "llama_cpp", "local_calendar", "local_file", "local_ip", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 58949fe0594e..400d0ac4fb54 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3887,6 +3887,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "llama_cpp": { + "name": "llama.cpp", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "llamalab_automate": { "name": "LlamaLab Automate", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 519bd1cb4c6b..9791660c9a21 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3227,6 +3227,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.llama_cpp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.local_ip.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 48bddfa31af6..1841242cdbb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1752,6 +1752,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.cloud +# homeassistant.components.llama_cpp # homeassistant.components.open_router # homeassistant.components.openai_conversation # homeassistant.components.ovhcloud_ai_endpoints diff --git a/tests/components/llama_cpp/__init__.py b/tests/components/llama_cpp/__init__.py new file mode 100644 index 000000000000..f201820fe927 --- /dev/null +++ b/tests/components/llama_cpp/__init__.py @@ -0,0 +1 @@ +"""Tests for the llama.cpp integration.""" diff --git a/tests/components/llama_cpp/conftest.py b/tests/components/llama_cpp/conftest.py new file mode 100644 index 000000000000..876a1e9bca9d --- /dev/null +++ b/tests/components/llama_cpp/conftest.py @@ -0,0 +1,167 @@ +"""Fixtures for llama.cpp integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass, field +import logging +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.llama_cpp.const import ( + CONF_BASE_URL, + DEFAULT_BASE_URL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONFIG_ENTRY_DATA = { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: DEFAULT_BASE_URL, +} +ASSIST_OPTIONS = {CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + + +@pytest.fixture(autouse=True) +async def setup_home_assistant(hass: HomeAssistant) -> None: + """Enable dependencies.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture for platforms loaded by the integration.""" + return [] + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> AsyncGenerator[None]: + """Set up the integration.""" + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> dict[str, Any]: + """Fixture to add data to the config entry.""" + return {} + + +@pytest.fixture(name="config_entry_options") +def config_entry_options_fixture() -> dict[str, Any]: + """Fixture to add options to the config entry.""" + return {} + + +@pytest.fixture(name="mock_config_entry") +def mock_config_entry_fixture( + hass: HomeAssistant, + config_entry_data: dict[str, Any], + config_entry_options: dict[str, Any], +) -> MockConfigEntry: + """Fixture to create a configuration entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="llama.cpp", + data={ + **CONFIG_ENTRY_DATA, + **config_entry_data, + }, + version=1, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data={**config_entry_options}, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ), + ], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@dataclass +class MockChatLog(conversation.ChatLog): + """Mock chat log.""" + + _mock_tool_results: dict[str, Any] = field(default_factory=dict) + + def mock_tool_results(self, results: dict[str, Any]) -> None: + """Set tool results.""" + self._mock_tool_results = results + + @property + def llm_api(self) -> llm.APIInstance | None: + """Return LLM API.""" + return self._llm_api + + @llm_api.setter + def llm_api(self, value: llm.APIInstance | None) -> None: + """Set LLM API.""" + self._llm_api = value + + if not value: + return + + async def async_call_tool(tool_input: llm.ToolInput) -> llm.ToolResult: + """Call tool.""" + if tool_input.id not in self._mock_tool_results: + raise ValueError( + f"Tool {tool_input.id} not found ({self._mock_tool_results})" + ) + return self._mock_tool_results[tool_input.id] + + self._llm_api.async_call_tool = async_call_tool + + +@pytest.fixture +def mock_chat_log(hass: HomeAssistant) -> Generator[conversation.ChatLog]: + """Return mock chat logs.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch( + "homeassistant.components.conversation.chat_log.ChatLog", + MockChatLog, + ), + chat_session.async_get_chat_session(hass, "mock-conversation-id") as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + yield chat_log + + +@pytest.fixture(autouse=True) +def mock_models_list() -> Generator[AsyncMock]: + """Initialize integration.""" + with patch( + "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ) as mock_list: + yield mock_list + + +@pytest.fixture(name="mock_completion", autouse=True) +def mock_openai_client_fixture() -> Generator[AsyncMock]: + """Fixture to mock the OpenAI client.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create: + yield mock_create diff --git a/tests/components/llama_cpp/snapshots/test_conversation.ambr b/tests/components/llama_cpp/snapshots/test_conversation.ambr new file mode 100644 index 000000000000..d5853b783cf7 --- /dev/null +++ b/tests/components/llama_cpp/snapshots/test_conversation.ambr @@ -0,0 +1,92 @@ +# serializer version: 1 +# name: test_conversation_entity + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[config_entry_options0] + list([ + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_exception[-config_entry_options0] + 'Unexpected tool argument response: Expecting value: line 1 column 1 (char 0)' +# --- +# name: test_function_exception[{"para-config_entry_options0] + 'Unexpected tool argument response: Unterminated string starting at: line 1 column 2 (char 1)' +# --- +# name: test_unknown_hass_api[config_entry_options0] + dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API', + }), + }), + }), + }) +# --- diff --git a/tests/components/llama_cpp/test_config_flow.py b/tests/components/llama_cpp/test_config_flow.py new file mode 100644 index 000000000000..dbd0a938ff20 --- /dev/null +++ b/tests/components/llama_cpp/test_config_flow.py @@ -0,0 +1,585 @@ +"""Tests for the llama.cpp config flow.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import openai +import pytest + +from homeassistant import config_entries +from homeassistant.components.llama_cpp.const import ( + CONF_BASE_URL, + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_RECOMMENDED, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_MODEL, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import llm + +from tests.common import MockConfigEntry + +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_CHAT_MODEL: DEFAULT_MODEL, +} + + +@pytest.fixture(name="mock_setup") +def mock_setup(hass: HomeAssistant) -> Generator[AsyncMock]: + """Mock the setup of the integration.""" + with patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_config_flow( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test selecting a model in the configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "http://localhost:8080/v1" + assert result.get("data") == { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + CONF_STREAMING: True, + } + assert result["options"] == {} + assert result["subentries"] == [ + { + "subentry_type": "conversation", + "data": { + **RECOMMENDED_OPTIONS, + CONF_CHAT_MODEL: "gpt-4", + }, + "title": "Gpt 4", + "unique_id": None, + }, + ] + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + openai.APIConnectionError(request=httpx.Request(method="POST", url="test")), + "cannot_connect", + ), + ( + openai.AuthenticationError( + message="Invalid key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="POST", url="test"), + ), + body=None, + ), + "invalid_auth", + ), + ( + openai.OpenAIError("Generic error"), + "api_error", + ), + ], +) +async def test_config_flow_fail_completion( + hass: HomeAssistant, + mock_setup: AsyncMock, + mock_completion: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test config flow where the API completion validation fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + mock_completion.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": expected_error} + + assert len(mock_setup.mock_calls) == 0 + + +async def test_config_flow_no_streaming( + hass: HomeAssistant, + mock_setup: AsyncMock, + mock_completion: AsyncMock, +) -> None: + """Test config flow where the API does not support streaming.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + def fail_streaming(stream: bool | None = None, **kwargs: Any) -> None: + """Allow first check to succeed by fail streaming.""" + if stream: + raise openai.OpenAIError("Invalid request") + + mock_completion.side_effect = fail_streaming + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "http://localhost:8080/v1" + assert result.get("data") == { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + CONF_STREAMING: False, + } + assert result["subentries"] == [ + { + "subentry_type": "conversation", + "data": { + **RECOMMENDED_OPTIONS, + CONF_CHAT_MODEL: "gpt-4", + }, + "title": "Gpt 4", + "unique_id": None, + }, + ] + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + RECOMMENDED_OPTIONS, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Gpt 3.5 Turbo" + + assert result2["data"] == RECOMMENDED_OPTIONS + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry handles connection errors.""" + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError(request=None), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_advanced( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry with custom/advanced settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Toggle recommended to False to show advanced options + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "init" + + # Now configure the advanced options + result3 = await hass.config_entries.subentries.async_configure( + result2["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + CONF_MAX_TOKENS: 500, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Gpt 4" + assert result3["data"] == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + CONF_MAX_TOKENS: 500, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + } + + +async def test_config_flow_model_selection_fallbacks( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test model selection fallback options through the config flow.""" + # 1. Test empty list fallback (should fallback to DEFAULT_MODEL) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + async def mock_empty_list(*args, **kwargs): + return + yield + + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=mock_empty_list, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "model" + schema = result2["data_schema"].schema + chat_model_key = next(k for k in schema if k == CONF_CHAT_MODEL) + assert chat_model_key.description["suggested_value"] == DEFAULT_MODEL + + # 2. Test no recommended models match fallback (should select first model in the list) + result_custom = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + model1 = MagicMock() + model1.id = "my-custom-model-1" + model2 = MagicMock() + model2.id = "my-custom-model-2" + + async def mock_custom_list(*args, **kwargs): + yield model1 + yield model2 + + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=mock_custom_list, + ): + result3 = await hass.config_entries.flow.async_configure( + result_custom["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "model" + schema = result3["data_schema"].schema + chat_model_key = next(k for k in schema if k == CONF_CHAT_MODEL) + assert chat_model_key.description["suggested_value"] == "my-custom-model-1" + + +async def test_config_flow_connection_errors( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test config flow handles connection validation errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # 1. Test AuthenticationError + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.AuthenticationError( + message="Invalid Key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="GET", url="test"), + ), + body=None, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # 2. Test APIConnectionError + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError( + request=httpx.Request(method="GET", url="test") + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "cannot_connect"} + + # 3. Test OpenAIError (Generic API errors) + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.OpenAIError("generic error"), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result4["type"] is FlowResultType.FORM + assert result4["errors"] == {"base": "api_error"} + + +@pytest.mark.usefixtures("setup_integration") +async def test_reconfiguring_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an existing conversation subentry.""" + subentry = list(mock_config_entry.subentries.values())[0] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "New prompt", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + updated_subentry = list(mock_config_entry.subentries.values())[0] + assert updated_subentry.title == "Gpt 4" + assert updated_subentry.data[CONF_CHAT_MODEL] == "gpt-4" + assert updated_subentry.data[CONF_PROMPT] == "New prompt" + assert CONF_STREAMING not in updated_subentry.data + + +async def test_subentry_options_entry_not_loaded( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow aborts if config entry is not loaded.""" + subentry = list(mock_config_entry.subentries.values())[0] + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_reconfiguring_conversation_subentry_connection_error( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry aborts if model listing fails.""" + subentry = list(mock_config_entry.subentries.values())[0] + + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError(request=None), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_reconfiguring_conversation_subentry_validation_error( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry shows form with error if model validation fails.""" + subentry = list(mock_config_entry.subentries.values())[0] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=openai.OpenAIError("generic error"), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "New prompt", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "api_error"} + + +async def test_config_flow_unexpected_exception( + hass: HomeAssistant, +) -> None: + """Test user step handles unexpected exception by showing unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.llama_cpp.config_flow.async_create_client", + side_effect=RuntimeError("Unexpected error"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/llama_cpp/test_conversation.py b/tests/components/llama_cpp/test_conversation.py new file mode 100644 index 000000000000..e35445e50616 --- /dev/null +++ b/tests/components/llama_cpp/test_conversation.py @@ -0,0 +1,572 @@ +"""Tests for the llama.cpp conversation platform.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import httpx +import openai +from openai.types.chat import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + ChatCompletionMessageToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta +from openai.types.chat.chat_completion_message_tool_call import Function +from openai.types.completion_usage import CompletionUsage +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.components.llama_cpp.const import CONF_STREAMING +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + +from .conftest import ASSIST_OPTIONS, MockChatLog + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def freeze_the_time() -> Generator[None]: + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.fixture(autouse=True) +def mock_ulid() -> Generator[AsyncMock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.llm.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +async def mock_setup_integration_fixture( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Setup the integration.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_conversation_entity( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Verify the conversation entity is loaded.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test function call from the assistant.""" + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + } + ) + + def completion_result( + *args: Any, messages: list[dict[str, Any]] | list[Any], **kwargs: Any + ) -> ChatCompletion: + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +@pytest.mark.parametrize( + ("tool_arguments"), + [ + (""), + ('{"para'), + ], +) +async def test_function_exception( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + tool_arguments: str, + snapshot: SnapshotAssertion, +) -> None: + """Test function call with exception.""" + + def completion_result( + *args: Any, messages: list[dict[str, Any]] | list[Any], **kwargs: Any + ) -> ChatCompletion: + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="There was an error calling the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", + function=Function( + arguments=tool_arguments, + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.speech["plain"]["speech"] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +async def test_assist_api_tools_conversion( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that we are able to convert actual tools from Assist API.""" + for component in ( + "intent", + "todo", + "light", + "shopping_list", + "humidifier", + "climate", + "media_player", + "vacuum", + "cover", + "weather", + ): + assert await async_setup_component(hass, component, {}) + + agent_id = mock_config_entry.entry_id + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) as mock_create: + await conversation.async_converse(hass, "hello", None, None, agent_id=agent_id) + + tools = mock_create.mock_calls[0][2]["tools"] + assert tools + + +@pytest.mark.parametrize(("config_entry_options"), [{CONF_STREAMING: True}]) +async def test_streaming_response( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, +) -> None: + """Test streaming response from the assistant.""" + + async def mock_stream() -> AsyncGenerator[ChatCompletionChunk]: + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content="Hello"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(content=" world"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(), + finish_reason="stop", + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=mock_stream(), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello world" + + content = mock_chat_log.content[1:] + assert len(content) == 2 + assert content[0].role == "user" + assert content[0].content == "hello" + assert content[1].role == "assistant" + assert content[1].content == "Hello world" + + +@pytest.mark.parametrize(("config_entry_options"), [{CONF_STREAMING: True}]) +async def test_streaming_response_redundant_role( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, +) -> None: + """Test streaming response where every chunk redundantly includes the role.""" + + async def mock_stream() -> AsyncGenerator[ChatCompletionChunk]: + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content="Hello"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content=" world"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(role="assistant"), + finish_reason="stop", + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=mock_stream(), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello world" + + content = mock_chat_log.content[1:] + assert len(content) == 2 + assert content[0].role == "user" + assert content[0].content == "hello" + assert content[1].role == "assistant" + assert content[1].content == "Hello world" + + +@pytest.mark.parametrize( + ("config_entry_options"), [{CONF_LLM_HASS_API: ["non-existing"]}] +) +async def test_unknown_hass_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an API that no longer exists.""" + result = await conversation.async_converse( + hass, "hello", "conversation-id", Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.as_dict() == snapshot + + +async def test_conversation_agent_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of OpenAI API connection errors in conversation entity.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=openai.APIConnectionError( + request=httpx.Request(method="POST", url="test") + ), + ): + result = await conversation.async_converse( + hass, + "hello", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.speech["plain"]["speech"] + == "Cannot connect to the server: Connection error." + ) + + +async def test_conversation_agent_structured_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of OpenAI API structured errors in conversation entity.""" + response = httpx.Response( + status_code=402, + request=httpx.Request( + method="POST", url="https://api.openai.com/v1/chat/completions" + ), + json={ + "error": { + "message": "Insufficient Balance", + "type": "unknown_error", + "param": None, + "code": "invalid_request_error", + } + }, + ) + err = openai.APIStatusError( + message="Error code: 402 - {'error': {'message': 'Insufficient Balance'}}", + response=response, + body=response.json(), + ) + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=err, + ): + result = await conversation.async_converse( + hass, + "hello", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.speech["plain"]["speech"] + == "Your account or API key has insufficient credits: Insufficient Balance" + ) diff --git a/tests/components/llama_cpp/test_init.py b/tests/components/llama_cpp/test_init.py new file mode 100644 index 000000000000..91136cfa170b --- /dev/null +++ b/tests/components/llama_cpp/test_init.py @@ -0,0 +1,68 @@ +"""Tests for llama.cpp integration setup.""" + +from unittest.mock import AsyncMock, patch + +import httpx +import openai +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting up and unloading llama.cpp entry.""" + with patch( + "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + openai.AuthenticationError( + message="Invalid API key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="GET", url="test"), + ), + body=None, + ), + ConfigEntryState.SETUP_ERROR, + ), + ( + openai.APIConnectionError(request=None), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry failure handling.""" + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=side_effect, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state From 536880ffeb60ae178da12cec335be47df3e5bd10 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Sat, 4 Jul 2026 16:21:02 +1000 Subject: [PATCH 053/707] Correct availability check for Huntedouglas Powerview tilt-only shades (#175350) --- .../components/hunterdouglas_powerview/cover.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 35c955fa5c95..0cd4816f379d 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -139,7 +139,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): @override def available(self) -> bool: """Return True if shade position data is available.""" - return super().available and self.positions.primary is not None + return super().available and self._is_position_available + + @property + def _is_position_available(self) -> bool: + """Return if the cover contains positional data.""" + return self.positions.primary is not None @property @override @@ -567,9 +572,9 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): @property @override - def available(self) -> bool: - """Return True if shade position data is available.""" - return super().available and self.positions.tilt is not None + def _is_position_available(self) -> bool: + """Return if the cover contains positional data.""" + return self.positions.tilt is not None class PowerViewShadeTopDown(PowerViewShadeBase): From 21fc546c6e846148b486fb87339a0d379440618e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Jul 2026 08:46:17 +0200 Subject: [PATCH 054/707] Expose all LLM APIs on the MCP server keyed by API ID (#175570) Co-authored-by: Claude --- homeassistant/components/mcp_server/http.py | 144 ++++++++++++-------- tests/components/mcp_server/test_http.py | 82 ++++++++++- 2 files changed, 170 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 3af6fb4806a3..4317bb3d5494 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -3,12 +3,16 @@ This registers HTTP endpoints that support the Streamable HTTP protocol as well as the older SSE as a transport layer. -The Streamable HTTP protocol uses a single HTTP endpoint: +The Streamable HTTP protocol uses these HTTP endpoints: -- /api/mcp_server: The Streamable HTTP endpoint currently implements the +- /api/mcp: The Streamable HTTP endpoint currently implements the stateless protocol for simplicity. This receives client requests and sends them to the MCP server, then waits for a response to send back to - the client. + the client. This serves the configured LLM APIs and does not require + admin access. +- /api/mcp/: The same Streamable HTTP endpoint, but exposing a + specific LLM API selected by its ID. These endpoints require admin access, + except for the Assist API. The older SSE protocol has two HTTP endpoints: @@ -43,6 +47,7 @@ from homeassistant.components import conversation from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import Unauthorized from homeassistant.helpers import llm from .const import DOMAIN @@ -67,6 +72,7 @@ def async_register(hass: HomeAssistant) -> None: hass.http.register_view(ModelContextProtocolSSEView()) hass.http.register_view(ModelContextProtocolMessagesView()) hass.http.register_view(ModelContextProtocolStreamableView()) + hass.http.register_view(ModelContextProtocolStreamableApiView()) def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: @@ -127,7 +133,7 @@ async def create_streams() -> AsyncGenerator[Streams]: async def create_mcp_server( - hass: HomeAssistant, context: Context, entry: MCPServerConfigEntry + hass: HomeAssistant, context: Context, llm_api_id: str | list[str] ) -> tuple[Server, InitializationOptions]: """Initialize the MCP server to ensure it's ready to handle requests.""" llm_context = llm.LLMContext( @@ -137,7 +143,6 @@ async def create_mcp_server( assistant=conversation.DOMAIN, device_id=None, ) - llm_api_id = entry.data[CONF_LLM_HASS_API] server = await create_server(hass, llm_api_id, llm_context) options = await hass.async_add_executor_job( server.create_initialization_options # Reads package for version info @@ -165,7 +170,9 @@ class ModelContextProtocolSSEView(HomeAssistantView): entry = async_get_config_entry(hass) session_manager = entry.runtime_data - server, options = await create_mcp_server(hass, self.context(request), entry) + server, options = await create_mcp_server( + hass, self.context(request), entry.data[CONF_LLM_HASS_API] + ) async with ( create_streams() as streams, @@ -231,65 +238,92 @@ class ModelContextProtocolMessagesView(HomeAssistantView): return web.Response(status=200) +async def _async_handle_streamable_message( + request: web.Request, context: Context, llm_api_id: str | list[str] +) -> web.StreamResponse: + """Process a single JSON-RPC message for the given LLM API.""" + hass = request.app[KEY_HASS] + + # The request must include a JSON-RPC message + if CONTENT_TYPE_JSON not in request.headers.get("accept", ""): + raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}") + if request.content_type != CONTENT_TYPE_JSON: + raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}") + try: + json_data = await request.json() + message = types.JSONRPCMessage.model_validate(json_data) + except ValueError as err: + _LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err) + raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err + + _LOGGER.debug("Received client message: %s", message) + + # For notifications and responses only, return 202 Accepted + if not isinstance(message.root, JSONRPCRequest): + _LOGGER.debug("Notification or response received, returning 202") + return web.Response(status=HTTPStatus.ACCEPTED) + + # The MCP server runs as a background task for the duration of the + # request. We open a buffered stream pair to communicate with it. The + # request is sent to the MCP server and we wait for a single response + # then shut down the server. + server, options = await create_mcp_server(hass, context, llm_api_id) + + async with create_streams() as streams: + + async def run_server() -> None: + await server.run( + streams.read_stream, streams.write_stream, options, stateless=True + ) + + async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: + tg.start_soon(run_server) + + await streams.read_stream_writer.send(SessionMessage(message)) + session_message = await anext(streams.write_stream_reader) + tg.cancel_scope.cancel() + + _LOGGER.debug("Sending response: %s", session_message) + return web.json_response( + data=session_message.message.model_dump(by_alias=True, exclude_none=True), + ) + + class ModelContextProtocolStreamableView(HomeAssistantView): - """Model Context Protocol Streamable HTTP endpoint.""" + """Model Context Protocol Streamable HTTP endpoint. + + This serves the configured LLM APIs and does not require admin access. + """ name = f"{DOMAIN}:streamable" url = STREAMABLE_API - async def get(self, request: web.Request) -> web.StreamResponse: - """Handle unsupported methods.""" - return web.Response( - status=HTTPStatus.METHOD_NOT_ALLOWED, text="Only POST method is supported" - ) - async def post(self, request: web.Request) -> web.StreamResponse: - """Process JSON-RPC messages for the Model Context Protocol.""" + """Process JSON-RPC messages for the configured LLM APIs.""" hass = request.app[KEY_HASS] entry = async_get_config_entry(hass) + return await _async_handle_streamable_message( + request, self.context(request), entry.data[CONF_LLM_HASS_API] + ) - # The request must include a JSON-RPC message - if CONTENT_TYPE_JSON not in request.headers.get("accept", ""): - raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}") - if request.content_type != CONTENT_TYPE_JSON: - raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}") - try: - json_data = await request.json() - message = types.JSONRPCMessage.model_validate(json_data) - except ValueError as err: - _LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err) - raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err - _LOGGER.debug("Received client message: %s", message) +class ModelContextProtocolStreamableApiView(HomeAssistantView): + """Model Context Protocol Streamable HTTP endpoint for a specific LLM API. - # For notifications and responses only, return 202 Accepted - if not isinstance(message.root, JSONRPCRequest): - _LOGGER.debug("Notification or response received, returning 202") - return web.Response(status=HTTPStatus.ACCEPTED) + The LLM API is selected by its ID in the URL. These endpoints require + admin access, except for the Assist API. + """ - # The MCP server runs as a background task for the duration of the - # request. We open a buffered stream pair to communicate with it. The - # request is sent to the MCP server and we wait for a single response - # then shut down the server. - server, options = await create_mcp_server(hass, self.context(request), entry) + name = f"{DOMAIN}:streamable_api" + url = f"{STREAMABLE_API}/{{api_id}}" - async with create_streams() as streams: - - async def run_server() -> None: - await server.run( - streams.read_stream, streams.write_stream, options, stateless=True - ) - - async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: - tg.start_soon(run_server) - - await streams.read_stream_writer.send(SessionMessage(message)) - session_message = await anext(streams.write_stream_reader) - tg.cancel_scope.cancel() - - _LOGGER.debug("Sending response: %s", session_message) - return web.json_response( - data=session_message.message.model_dump( - by_alias=True, exclude_none=True - ), - ) + async def post(self, request: web.Request, api_id: str) -> web.StreamResponse: + """Process JSON-RPC messages for the LLM API identified by api_id.""" + hass = request.app[KEY_HASS] + if api_id != llm.LLM_API_ASSIST and not request["hass_user"].is_admin: + raise Unauthorized + if api_id not in {api.id for api in llm.async_get_apis(hass)}: + raise HTTPNotFound(text=f"Unknown LLM API '{api_id}'") + return await _async_handle_streamable_message( + request, self.context(request), api_id + ) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index e0004a274e28..d05640414faa 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -26,7 +26,12 @@ from homeassistant.components.mcp_server.http import ( STREAMABLE_API, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON +from homeassistant.const import ( + CONF_LLM_HASS_API, + CONTENT_TYPE_JSON, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -633,3 +638,78 @@ async def test_mcp_tool_call_unicode( response_text = result.content[0].text assert "这是一个测试" in response_text assert "\\u" not in response_text + + +async def test_streamable_api_id_exposes_registered_api( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_supervisor_access_token: str, +) -> None: + """Test the keyed endpoint exposes any registered API, not just the configured one.""" + llm.async_register_api( + hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API") + ) + + client = await hass_client() + mcp_url = str(client.make_url(f"{STREAMABLE_API}/{TEST_LLM_API_ID}")) + + async with mcp_streamable_session( + hass, mcp_url, hass_supervisor_access_token + ) as session: + result = await session.list_prompts() + + assert len(result.prompts) == 1 + assert result.prompts[0].name == "Test API" + + +async def test_streamable_api_id_requires_admin( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test a non-Assist keyed endpoint requires an admin user.""" + llm.async_register_api( + hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API") + ) + + client = await hass_client(hass_read_only_access_token) + response = await client.post( + f"{STREAMABLE_API}/{TEST_LLM_API_ID}", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.UNAUTHORIZED + + +async def test_streamable_api_id_assist_allows_non_admin( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test the Assist keyed endpoint does not require an admin user.""" + client = await hass_client(hass_read_only_access_token) + response = await client.post( + f"{STREAMABLE_API}/{llm.LLM_API_ASSIST}", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.OK + + +async def test_streamable_api_id_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test the keyed endpoint returns 404 for an unknown API ID.""" + client = await hass_client() + response = await client.post( + f"{STREAMABLE_API}/does-not-exist", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.NOT_FOUND + assert "Unknown LLM API" in await response.text() From a2e13fc839e9390c8da03025009c0a8990ec9d7e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:36:44 +0200 Subject: [PATCH 055/707] Update syrupy to 5.3.4 (#175575) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index bd0ad2d1b002..7378f18e9778 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.3.2 +syrupy==5.3.4 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 From eed88e2c72357f7499a0770aa373128080c05e00 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 4 Jul 2026 10:49:10 +0200 Subject: [PATCH 056/707] Support KNX button entity configuration from UI (#174426) --- homeassistant/components/knx/button.py | 98 ++++++++++-- homeassistant/components/knx/const.py | 2 + homeassistant/components/knx/dpt.py | 46 +++++- homeassistant/components/knx/schema.py | 10 +- homeassistant/components/knx/storage/const.py | 3 + .../knx/storage/entity_store_schema.py | 57 ++++++- .../components/knx/storage/knx_selector.py | 77 +++++++++- homeassistant/components/knx/strings.json | 26 ++++ .../knx/fixtures/config_store_button.json | 44 ++++++ .../knx/snapshots/test_websocket.ambr | 33 ++++ tests/components/knx/test_button.py | 141 +++++++++++++++++- 11 files changed, 508 insertions(+), 29 deletions(-) create mode 100644 tests/components/knx/fixtures/config_store_button.json diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 718c76aca29e..f652725d5152 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -1,19 +1,24 @@ """Support for KNX button entities.""" -from typing import override +from typing import Any, override -from xknx.devices import RawValue as XknxRawValue +from xknx.devices import ExposeSensor as XknxExposeSensor, RawValue as XknxRawValue from homeassistant import config_entries from homeassistant.components.button import ButtonEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType -from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule +from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -21,27 +26,60 @@ async def async_setup_entry( config_entry: config_entries.ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the KNX binary sensor platform.""" + """Set up button(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.BUTTON] + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.BUTTON, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiButton, + ), + ) - async_add_entities(KNXButton(knx_module, entity_config) for entity_config in config) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.BUTTON): + entities.extend( + KnxYamlButton(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.BUTTON): + entities.extend( + KnxUiButton(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXButton(KnxYamlEntity, ButtonEntity): +class _KnxButton(ButtonEntity): """Representation of a KNX button.""" + _device: XknxRawValue | XknxExposeSensor + _payload: Any + + @override + async def async_press(self) -> None: + """Press the button.""" + await self._device.set(self._payload) + + +class KnxYamlButton(_KnxButton, KnxYamlEntity): + """Representation of a KNX button configured via YAML.""" + _device: XknxRawValue def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: """Initialize a KNX button.""" + # dpt-value to payload conversion is done in schema validation for yaml config + self._payload = config[CONF_PAYLOAD] self._device = XknxRawValue( xknx=knx_module.xknx, name=config[CONF_NAME], payload_length=config[CONF_PAYLOAD_LENGTH], group_address=config[KNX_ADDRESS], ) - self._payload = config[CONF_PAYLOAD] super().__init__( knx_module=knx_module, unique_id=f"{self._device.remote_value.group_address}_{self._payload}", @@ -49,7 +87,39 @@ class KNXButton(KnxYamlEntity, ButtonEntity): entity_category=config.get(CONF_ENTITY_CATEGORY), ) - @override - async def async_press(self) -> None: - """Press the button.""" - await self._device.set(self._payload) + +class KnxUiButton(_KnxButton, KnxUiEntity): + """Representation of a KNX button configured via the UI.""" + + _device: XknxRawValue | XknxExposeSensor + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize a KNX button.""" + knx_conf = ConfigExtractor(config[DOMAIN]) + button_data = knx_conf.get(CONF_DATA) + if CONF_PAYLOAD in button_data and CONF_PAYLOAD_LENGTH in button_data: + self._payload = int(button_data[CONF_PAYLOAD], 16) + self._device = XknxRawValue( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + payload_length=button_data[CONF_PAYLOAD_LENGTH], + group_address=knx_conf.get_write(CONF_GA_SEND), + ) + else: + dpt_string = knx_conf.get_dpt(CONF_GA_SEND) + self._payload = button_data[CONF_VALUE] + self._device = XknxExposeSensor( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + value_type=dpt_string, + group_address=knx_conf.get_write(CONF_GA_SEND), + respond_to_read=False, + ) + + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index b12d6241bb50..84f73b4255e2 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -26,6 +26,7 @@ KNX_ADDRESS: Final = "address" CONF_INVERT: Final = "invert" CONF_KNX_EXPOSE: Final = "expose" CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address" +CONF_VALUE: Final = "value" ## # Connection constants @@ -178,6 +179,7 @@ SUPPORTED_PLATFORMS_YAML: Final = { SUPPORTED_PLATFORMS_UI: Final = { Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.DATE, diff --git a/homeassistant/components/knx/dpt.py b/homeassistant/components/knx/dpt.py index bb5792c00616..2ab86f0626a3 100644 --- a/homeassistant/components/knx/dpt.py +++ b/homeassistant/components/knx/dpt.py @@ -2,9 +2,9 @@ from collections.abc import Mapping from functools import cache -from typing import Literal, TypedDict +from typing import Literal, NotRequired, TypedDict, cast -from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric +from xknx.dpt import DPTBase, DPTComplex, DPTComplexFieldSchema, DPTEnum, DPTNumeric from xknx.dpt.dpt_16 import DPTString from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -24,15 +24,28 @@ class DPTInfo(TypedDict): sensor_device_class: SensorDeviceClass | None sensor_state_class: SensorStateClass | None + payload_length: int + + # numeric specific + min: NotRequired[float] + max: NotRequired[float] + step: NotRequired[float] + + # enum specific + options: NotRequired[list[str]] + + # complex specific + schema: NotRequired[list[DPTComplexFieldSchema]] + @cache def get_supported_dpts() -> Mapping[str, DPTInfo]: """Return a mapping of supported DPTs with HA specific attributes.""" - dpts = {} + dpts: dict[str, DPTInfo] = {} for dpt_class in DPTBase.dpt_class_tree(): dpt_number_str = dpt_class.dpt_number_str() ha_dpt_class = _ha_dpt_class(dpt_class) - dpts[dpt_number_str] = DPTInfo( + info = DPTInfo( dpt_class=ha_dpt_class, main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests sub=dpt_class.dpt_sub_number, @@ -40,7 +53,15 @@ def get_supported_dpts() -> Mapping[str, DPTInfo]: unit=_sensor_unit_overrides.get(dpt_number_str, dpt_class.unit), sensor_device_class=_sensor_device_classes.get(dpt_number_str), sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str), + payload_length=dpt_class.payload_length, ) + if ha_dpt_class == "numeric": + _add_numeric_details(info, cast(type[DPTNumeric], dpt_class)) + elif ha_dpt_class == "enum": + _add_enum_details(info, cast(type[DPTEnum], dpt_class)) + elif ha_dpt_class == "complex": + _add_complex_details(info, cast(type[DPTComplex], dpt_class)) + dpts[dpt_number_str] = info return dpts @@ -57,6 +78,23 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass: raise ValueError("Unsupported DPT class") +def _add_numeric_details(dpt_info: DPTInfo, dpt_cls: type[DPTNumeric]) -> None: + """Add numeric specific details to the DPTInfo.""" + dpt_info["min"] = dpt_cls.value_min + dpt_info["max"] = dpt_cls.value_max + dpt_info["step"] = dpt_cls.resolution + + +def _add_enum_details(dpt_info: DPTInfo, dpt_cls: type[DPTEnum]) -> None: + """Add enum specific details to the DPTInfo.""" + dpt_info["options"] = [o.name.lower() for o in dpt_cls.get_valid_values()] + + +def _add_complex_details(dpt_info: DPTInfo, dpt_cls: type[DPTComplex]) -> None: + """Add complex specific details to the DPTInfo.""" + dpt_info["schema"] = dpt_cls.get_dict_schema() + + _sensor_device_classes: Mapping[str, SensorDeviceClass] = { "7.011": SensorDeviceClass.DISTANCE, "7.012": SensorDeviceClass.CURRENT, diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 0d416c7b7a1b..7c200a77bc37 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -57,6 +57,7 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, + CONF_VALUE, KNX_ADDRESS, ClimateConf, ColorTempModes, @@ -98,9 +99,12 @@ def _max_payload_value(payload_length: int) -> int: def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict: - """Validate a button entity payload configuration.""" + """Validate a button entity payload configuration. + + Returns raw payload and length from value and type (DPT), if given. + """ if _type := entity_config.get(CONF_TYPE): - _payload = entity_config[ButtonSchema.CONF_VALUE] + _payload = entity_config[CONF_VALUE] if (transcoder := DPTBase.parse_transcoder(_type)) is None: raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.") entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length @@ -234,8 +238,6 @@ class ButtonSchema(KNXPlatformSchema): PLATFORM = Platform.BUTTON - CONF_VALUE = "value" - payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`" length_or_type_msg = ( f"Please use only one of `{CONF_PAYLOAD_LENGTH}` or `{CONF_TYPE}`" diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index d9729684eb2e..530af816b435 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -19,6 +19,9 @@ CONF_GA_TIME: Final = "ga_time" CONF_GA_STEP: Final = "ga_step" +# Button +CONF_GA_SEND: Final = "ga_send" + # Climate CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current" CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7fffd6baaa0e..26d4c0286908 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -3,7 +3,8 @@ from enum import StrEnum, unique import voluptuous as vol -from xknx.dpt import DPTNumeric +from xknx.dpt import DPTBase, DPTBinary, DPTNumeric +from xknx.exceptions import ConversionError from homeassistant.components.climate import HVACMode from homeassistant.components.number import ( @@ -36,9 +37,11 @@ from ..const import ( CONF_CONTEXT_TIMEOUT, CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, + CONF_PAYLOAD_LENGTH, CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, + CONF_VALUE, DOMAIN, SUPPORTED_PLATFORMS_UI, ClimateConf, @@ -92,6 +95,7 @@ from .const import ( CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SCENE, + CONF_GA_SEND, CONF_GA_SENSOR, CONF_GA_SETPOINT_SHIFT, CONF_GA_SPEED, @@ -115,6 +119,7 @@ from .knx_selector import ( GASelector, GroupSelect, GroupSelectOption, + KnxPayloadSelector, KNXSectionFlat, SyncStateSelector, ) @@ -169,6 +174,55 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema( }, ) + +def _button_data_sub_validator(config: dict) -> dict: + """Validate data matching configured DPT.""" + dpt = config[CONF_GA_SEND].get(CONF_DPT) + transcoder = None + if dpt: + transcoder = DPTBase.parse_transcoder(dpt) + assert transcoder is not None # already checked by GASelector + + if CONF_VALUE in config[CONF_DATA]: + try: + transcoder.to_knx(config[CONF_DATA][CONF_VALUE]) + except ConversionError as ex: + raise vol.Invalid( + f"Value invalid for DPT {transcoder.dpt_number_str()}", + path=([CONF_DATA]), + ) from ex + elif CONF_PAYLOAD_LENGTH in config[CONF_DATA]: + length = config[CONF_DATA][CONF_PAYLOAD_LENGTH] + if length != transcoder.payload_length or ( + length != 0 and transcoder.payload_type is DPTBinary + ): + raise vol.Invalid( + f"Payload length invalid for DPT {transcoder.dpt_number_str()}", + path=([CONF_DATA]), + ) + return config + # without DPT only raw allowed -> payload + payload_length (checked by KnxPayloadSelector) + if CONF_PAYLOAD_LENGTH in config[CONF_DATA]: + return config + raise vol.Invalid("Invalid configuration for button entity") + + +BUTTON_KNX_SCHEMA = AllSerializeFirst( + vol.Schema( + { + vol.Required(CONF_GA_SEND): GASelector( + state=False, + write_required=True, + passive=False, + dpt=["numeric", "enum", "complex", "string"], + dpt_required=False, # for raw payload support + ), + vol.Required(CONF_DATA): KnxPayloadSelector(ga_path=CONF_GA_SEND), + }, + ), + _button_data_sub_validator, +) + COVER_KNX_SCHEMA = AllSerializeFirst( vol.Schema( { @@ -741,6 +795,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst( KNX_SCHEMA_FOR_PLATFORM = { Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA, + Platform.BUTTON: BUTTON_KNX_SCHEMA, Platform.CLIMATE: CLIMATE_KNX_SCHEMA, Platform.COVER: COVER_KNX_SCHEMA, Platform.DATE: DATE_KNX_SCHEMA, diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 9bc0a1cd382c..b216752a58ba 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -6,6 +6,9 @@ from typing import Any, override import voluptuous as vol +from homeassistant.const import CONF_PAYLOAD + +from ..const import CONF_PAYLOAD_LENGTH, CONF_VALUE from ..dpt import HaDptClass, get_supported_dpts from ..validation import ga_validator, maybe_ga_validator, sync_state_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE @@ -159,7 +162,11 @@ class GroupSelect(KNXSelectorBase): class GASelector(KNXSelectorBase): - """Selector for a KNX group address structure.""" + """Selector for a KNX group address structure. + + `dpt_required` optional dpt only apply to dpt-class lists, enums are always required. + `valid_dpt` is used in frontend to filter dropdown menu - no validation is done. + """ selector_type = "knx_group_address" @@ -171,6 +178,7 @@ class GASelector(KNXSelectorBase): write_required: bool = False, state_required: bool = False, dpt: type[Enum] | list[HaDptClass] | None = None, + dpt_required: bool = True, valid_dpt: str | Iterable[str] | None = None, ) -> None: """Initialize the group address selector.""" @@ -180,7 +188,7 @@ class GASelector(KNXSelectorBase): self.write_required = write_required self.state_required = state_required self.dpt = dpt - # valid_dpt is used in frontend to filter dropdown menu - no validation is done + self.dpt_required = dpt_required self.valid_dpt = (valid_dpt,) if isinstance(valid_dpt, str) else valid_dpt self.schema = self.build_schema() @@ -196,6 +204,7 @@ class GASelector(KNXSelectorBase): } if self.dpt is not None: if isinstance(self.dpt, list): + # optional / required is not passed to FE - only validated in BE options["dptClasses"] = self.dpt else: options["dptSelect"] = [ @@ -267,7 +276,8 @@ class GASelector(KNXSelectorBase): """Add DPT validator to the schema.""" if self.dpt is not None: if isinstance(self.dpt, list): - schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts()) + marker = vol.Required if self.dpt_required else vol.Optional + schema[marker(CONF_DPT)] = vol.In(get_supported_dpts()) else: schema[vol.Required(CONF_DPT)] = vol.In( {item.value for item in self.dpt} @@ -300,3 +310,64 @@ class SyncStateSelector(KNXSelectorBase): if not self.allow_false and not data: raise vol.Invalid(f"Sync state cannot be {data}") return self.schema(data) + + +class KnxPayloadSelector(KNXSelectorBase): + """Selector for KNX payload configuration. + + Raw payloads are stored as hex strings. + """ + + schema = vol.Any( + { + vol.Required(CONF_VALUE): object, + }, + { + vol.Required(CONF_PAYLOAD): str, + vol.Required(CONF_PAYLOAD_LENGTH): vol.All(int, vol.Range(min=0, max=14)), + }, + ) + selector_type = "knx_payload" + + def __init__(self, ga_path: str) -> None: + """Initialize the KNX payload selector.""" + self.ga_path = ga_path + + @override + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + return { + "type": self.selector_type, + "ga_path": self.ga_path, + } + + @override + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + validated = self.schema(data) + if CONF_PAYLOAD in validated and CONF_PAYLOAD_LENGTH in validated: + payload = validated[CONF_PAYLOAD] + payload_length = validated[CONF_PAYLOAD_LENGTH] + try: + int_payload = int(payload, 16) + except ValueError as ex: + raise vol.Invalid(f"Invalid payload format: {payload}") from ex + validated[CONF_PAYLOAD] = hex(int_payload) # prepends "0x" if not present + + if int_payload < 0: + raise vol.Invalid(f"Payload cannot be negative: {payload}") + if payload_length == 0: + # DPT 1,2,3 is marked length 0, has 6 bit size + if int_payload > 63: + raise vol.Invalid( + f"Payload exceeds DPT 1,2,3 limit of 0x3f (63): {payload}" + ) + else: + max_payload = (1 << (payload_length * 8)) - 1 + if int_payload > max_payload: + raise vol.Invalid( + f"Payload {payload} exceeds possible maximum for " + f"length {payload_length}: {hex(max_payload)}" + ) + # CONF_VALUE branch needs subvalidator as we don't have the DPT available here + return validated diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index b5af5bee9836..59ff173b8b20 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -453,6 +453,19 @@ } } }, + "button": { + "description": "Entity for sending predefined values.", + "knx": { + "data": { + "description": "The value sent when the button is pressed. The format of the value depends on the DPT of the configured address.", + "label": "Data" + }, + "ga_send": { + "description": "Group address the value is sent to.", + "label": "Address" + } + } + }, "climate": { "description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.", "knx": { @@ -1014,6 +1027,19 @@ "project": { "description": "Inspect imported group addresses", "title": "Project" + }, + "selectors": { + "knx-payload-selector": { + "dpt_missing": "No DPT selected – Typed mode not available", + "mode": { + "label": "Payload format", + "raw": "Raw payload", + "typed": "Typed value" + }, + "raw_length": "Payload length", + "raw_length_description": "Length of the raw payload in bytes. For DPT 1, 2 and 3 use `0`.", + "raw_payload": "Raw payload" + } } }, "device_automation": { diff --git a/tests/components/knx/fixtures/config_store_button.json b/tests/components/knx/fixtures/config_store_button.json new file mode 100644 index 000000000000..41adf5d283d8 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_button.json @@ -0,0 +1,44 @@ +{ + "version": 2, + "minor_version": 3, + "key": "knx/config_store.json", + "data": { + "entities": { + "button": { + "knx_es_01KVFEGP54VJW94TR9GQW2XA4R": { + "entity": { + "name": "test raw", + "device_info": null, + "entity_category": null + }, + "knx": { + "data": { + "payload": "0x1", + "payload_length": 1 + }, + "ga_send": { + "write": "1/1/1" + } + } + }, + "knx_es_01KVFEHE937CQGWP81RZNQ6D8E": { + "entity": { + "name": "test typed", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_send": { + "write": "1/1/2", + "dpt": "1.001" + }, + "data": { + "value": "on" + } + } + } + } + }, + "time_server": {} + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 072bfa46f0be..b355dcc3b51e 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -129,6 +129,39 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[button] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'name': 'ga_send', + 'options': dict({ + 'dptClasses': list([ + 'numeric', + 'enum', + 'complex', + 'string', + ]), + 'passive': False, + 'state': False, + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'ga_path': 'ga_send', + 'name': 'data', + 'required': True, + 'type': 'knx_payload', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[climate] dict({ 'id': 1, diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 38ccb36200b0..21d6e136e74b 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -2,22 +2,32 @@ from datetime import timedelta import logging +from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.knx.const import ( CONF_PAYLOAD_LENGTH, + CONF_VALUE, KNX_ADDRESS, KNX_MODULE_KEY, ) from homeassistant.components.knx.schema import ButtonSchema -from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_TYPE +from homeassistant.const import ( + CONF_NAME, + CONF_PAYLOAD, + CONF_TYPE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import async_capture_events, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_button_simple( @@ -83,7 +93,7 @@ async def test_button_type(hass: HomeAssistant, knx: KNXTestKit) -> None: ButtonSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - ButtonSchema.CONF_VALUE: 21.5, + CONF_VALUE: 21.5, CONF_TYPE: "2byte_float", } } @@ -125,7 +135,7 @@ async def test_button_invalid( ButtonSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - ButtonSchema.CONF_VALUE: conf_value, + CONF_VALUE: conf_value, CONF_TYPE: conf_type, } } @@ -139,3 +149,128 @@ async def test_button_invalid( assert "Setup failed for 'knx': Invalid config." in record.message assert hass.states.get("button.test") is None assert hass.data.get(KNX_MODULE_KEY) is None + + +@pytest.mark.parametrize( + "knx_config", + [ + ( + { + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "1", "payload_length": 1}, # raw payload + } + ), + ( + { + "ga_send": {"write": "1/1/1", "dpt": "5"}, # generic 1byte uint + "data": {"payload": "0x01", "payload_length": 1}, # raw payload + } + ), + ( + { + "ga_send": {"write": "1/1/1", "dpt": "5"}, # generic 1byte uint + "data": {"value": 1}, # typed value + } + ), + ], +) +async def test_button_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_config: dict[str, Any], +) -> None: + """Test creating a button.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.BUTTON, + entity_data={"name": "test"}, + knx_data=knx_config, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test"}, blocking=True + ) + await knx.assert_write("1/1/1", (1,)) + + +async def test_button_ui_load(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test loading a button from storage.""" + await knx.setup_integration(config_store_fixture="config_store_button.json") + + # Raw button configuration + knx.assert_state( + "button.test_raw", + STATE_UNKNOWN, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_raw"}, blocking=True + ) + await knx.assert_write("1/1/1", (1,)) + + # Typed button configuration + knx.assert_state( + "button.test_typed", + STATE_UNKNOWN, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_typed"}, blocking=True + ) + await knx.assert_write("1/1/2", True) + + +@pytest.mark.parametrize( + "knx_config", + [ + { # missing data + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + }, + { # missing DPT + "ga_send": {"write": "1/1/1"}, + "data": {"value": 1}, + }, + { # invalid value for DPT + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + "data": {"value": "not_valid"}, + }, + { # invalid length for DPT + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + "data": {"payload": "0x1", "payload_length": 1}, + }, + { # out of bound value for DPT + "ga_send": {"write": "1/1/1", "dpt": "5.001"}, + "data": {"value": 101}, + }, + { # out of bound value for length + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "0x100", "payload_length": 1}, + }, + { # out of bound value for zero-length + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "0x40", "payload_length": 0}, + }, + ], +) +async def test_button_ui_create_data_validation( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + knx_config: dict[str, Any], +) -> None: + """Test creating a button with invalid data.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": Platform.BUTTON, + "data": { + "entity": {"name": "test"}, + "knx": knx_config, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + assert res["result"]["error_base"] + assert res["result"]["errors"][0]["path"] From 3ba52a4d1f38e413d7e7d1c13ddb8475872d73aa Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 4 Jul 2026 02:38:47 -0700 Subject: [PATCH 057/707] Add options update listener to Rain Bird integration (#175571) --- homeassistant/components/rainbird/__init__.py | 9 +++++++++ tests/components/rainbird/test_init.py | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index a006f9ef6363..57f4f5f7e012 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -132,6 +132,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> entry.runtime_data = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + return True @@ -273,3 +275,10 @@ def _async_fix_device_id( async def async_unload_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_listener( + hass: HomeAssistant, entry: RainbirdConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index a8df4bf188c0..258ecd49fcab 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -557,3 +557,23 @@ async def test_reload_migration_with_leading_zero_mac( len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 1 ) + + +async def test_options_listener( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test options update listener reloading the config entry.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + # Changing options triggers reload + with patch( + "homeassistant.components.rainbird.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + hass.config_entries.async_update_entry(config_entry, options={"duration": 5}) + await hass.async_block_till_done() + + # The entry should have been reloaded + assert len(mock_setup_entry.mock_calls) == 1 From 5d4e298e3c53fac9bc77aef86de172036054934a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:18:31 +0200 Subject: [PATCH 058/707] Bump aioimmich to 0.16.0 (#175601) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index d0974f3e3bb2..4c4c4484f9d2 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.15.1"] + "requirements": ["aioimmich==0.16.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1841242cdbb9..828728189e44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -303,7 +303,7 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.15.1 +aioimmich==0.16.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 3709c36e07c64f2a8e93c994eb1d3dc2406fd3fa Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 4 Jul 2026 14:07:33 +0200 Subject: [PATCH 059/707] Capitalize "id" in media source (#175593) --- homeassistant/components/media_source/strings.json | 8 ++++---- tests/components/netatmo/test_media_source.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json index d12c41301909..9755af5f8a49 100644 --- a/homeassistant/components/media_source/strings.json +++ b/homeassistant/components/media_source/strings.json @@ -4,16 +4,16 @@ }, "exceptions": { "browse_media_failed": { - "message": "Failed to browse media with content id {media_content_id}: {error}" + "message": "Failed to browse media with content ID {media_content_id}: {error}" }, "resolve_media_failed": { - "message": "Failed to resolve media with content id {media_content_id}: {error}" + "message": "Failed to resolve media with content ID {media_content_id}: {error}" }, "search_media_failed": { - "message": "Failed to search media with content id {media_content_id}: {error}" + "message": "Failed to search media with content ID {media_content_id}: {error}" }, "search_not_supported": { - "message": "Search is not supported for media with content id {media_content_id}" + "message": "Search is not supported for media with content ID {media_content_id}" }, "unknown_media_source": { "message": "Unknown media source: {domain}" diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 6279f3ff429a..76abd3269cd0 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -58,7 +58,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: with pytest.raises(BrowseError) as excinfo: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/") assert str(excinfo.value) == ( - "Failed to browse media with content id media-source://netatmo/: " + "Failed to browse media with content ID media-source://netatmo/: " "Invalid media source URI" ) # Test successful listing From 669dd93e83d85b76e9f5669dfcf3ee7d081fd4eb Mon Sep 17 00:00:00 2001 From: Mattias Arrelid Date: Sat, 4 Jul 2026 16:27:54 +0200 Subject: [PATCH 060/707] Fix HomeKit select accessories not marking the active option as on (#175603) Co-authored-by: Claude Fable 5 --- .../components/homekit/type_switches.py | 2 +- .../components/homekit/test_type_switches.py | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index a0d9830de4f0..a9b2d45d6e87 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -556,6 +556,6 @@ class SelectSwitch(HomeAccessory): @override def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" - current_option = cleanup_name_for_homekit(new_state.state) + current_option = new_state.state for option, char in self.select_chars.items(): char.set_value(option == current_option) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 796caba2c0db..2bc060960df4 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -5,9 +5,11 @@ from datetime import timedelta from freezegun import freeze_time import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_VALUE, CHAR_CONFIGURED_NAME, + CHAR_NAME, SERV_OUTLET, TYPE_FAUCET, TYPE_SHOWER, @@ -618,6 +620,57 @@ async def test_input_select_switch( assert acc.select_chars["option3"].value is False +@pytest.mark.parametrize( + "domain", + ["input_select", "select"], +) +async def test_select_switch_with_options_needing_name_cleanup( + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event], domain: str +) -> None: + """Test select options altered by HomeKit name cleanup still sync state.""" + entity_id = f"{domain}.test" + options = ["always_on", "always on"] + + hass.states.async_set(entity_id, "always_on", {ATTR_OPTIONS: options}) + await hass.async_block_till_done() + acc = SelectSwitch(hass, hk_driver, "SelectSwitch", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + + outlets = [serv for serv in acc.services if serv.display_name == SERV_OUTLET] + assert [serv.get_characteristic(CHAR_NAME).value for serv in outlets] == [ + "always on", + "always on", + ] + assert [ + serv.get_characteristic(CHAR_CONFIGURED_NAME).value for serv in outlets + ] == ["always on", "always on"] + + assert {option: char.value for option, char in acc.select_chars.items()} == { + "always_on": True, + "always on": False, + } + + hass.states.async_set(entity_id, "always on", {ATTR_OPTIONS: options}) + await hass.async_block_till_done() + assert {option: char.value for option, char in acc.select_chars.items()} == { + "always_on": False, + "always on": True, + } + + call_select_option = async_mock_service(hass, domain, SERVICE_SELECT_OPTION) + acc.select_chars["always_on"].client_update_value(True) + await hass.async_block_till_done() + + assert call_select_option + assert call_select_option[0].data == { + "entity_id": entity_id, + "option": "always_on", + } + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + @pytest.mark.parametrize( "domain", ["button", "input_button"], From abd99f7ef1c199caedc14bbc1ff2647b9581e745 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 4 Jul 2026 18:38:23 +0300 Subject: [PATCH 061/707] Remove Shelly button migrations (#175617) --- homeassistant/components/shelly/button.py | 85 +------------- tests/components/shelly/test_button.py | 132 +--------------------- 2 files changed, 3 insertions(+), 214 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 1f71d5751003..94c3e4ce26ef 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -2,10 +2,9 @@ from collections.abc import Callable from dataclasses import dataclass -from functools import partial from typing import TYPE_CHECKING, Any, Final, override -from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS +from aioshelly.const import MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( @@ -15,16 +14,14 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_SLEEP_PERIOD, DOMAIN, - LOGGER, MODEL_FRANKEVER_WATER_VALVE, ROLE_GENERIC, SHELLY_GAS_MODELS, @@ -47,9 +44,6 @@ from .utils import ( get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_id, - get_rpc_key_ids, - get_rpc_key_instances, - get_rpc_role_by_key, get_virtual_component_ids, ) @@ -119,76 +113,6 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ ] -@callback -def async_migrate_unique_ids( - coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, - entity_entry: er.RegistryEntry, -) -> dict[str, Any] | None: - """Migrate button unique IDs.""" - if not entity_entry.entity_id.startswith("button"): - return None - - for key in ("reboot", "self_test", "mute", "unmute"): - old_unique_id = f"{coordinator.mac}_{key}" - if entity_entry.unique_id == old_unique_id: - new_unique_id = f"{coordinator.mac}-{key}" - LOGGER.debug( - "Migrating unique_id for %s entity from [%s] to [%s]", - entity_entry.entity_id, - old_unique_id, - new_unique_id, - ) - return { - "new_unique_id": entity_entry.unique_id.replace( - old_unique_id, new_unique_id - ) - } - - if not isinstance(coordinator, ShellyRpcCoordinator): - return None - - if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): - for _id in blutrv_key_ids: - key = f"{BLU_TRV_IDENTIFIER}:{_id}" - ble_addr: str = coordinator.device.config[key]["addr"] - old_unique_id = f"{ble_addr}_calibrate" - if entity_entry.unique_id == old_unique_id: - new_unique_id = f"{format_ble_addr(ble_addr)}-{key}-calibrate" - LOGGER.debug( - "Migrating unique_id for %s entity from [%s] to [%s]", - entity_entry.entity_id, - old_unique_id, - new_unique_id, - ) - return { - "new_unique_id": entity_entry.unique_id.replace( - old_unique_id, new_unique_id - ) - } - - if virtual_button_keys := get_rpc_key_instances( - coordinator.device.config, "button" - ): - for key in virtual_button_keys: - old_unique_id = f"{coordinator.mac}-{key}" - if entity_entry.unique_id == old_unique_id: - role = get_rpc_role_by_key(coordinator.device.config, key) - new_unique_id = f"{coordinator.mac}-{key}-button_{role}" - LOGGER.debug( - "Migrating unique_id for %s entity from [%s] to [%s]", - entity_entry.entity_id, - old_unique_id, - new_unique_id, - ) - return { - "new_unique_id": entity_entry.unique_id.replace( - old_unique_id, new_unique_id - ) - } - - return None - - async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, @@ -206,11 +130,6 @@ async def async_setup_entry( if TYPE_CHECKING: assert coordinator is not None - if coordinator.device.initialized: - await er.async_migrate_entries( - hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) - ) - # Remove the 'restart' button for sleeping devices as it was mistakenly # added in https://github.com/home-assistant/core/pull/154673 entry_sleep_period = config_entry.data[CONF_SLEEP_PERIOD] diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 44e2ff560599..152820ad21c1 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -9,7 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.shelly.const import DOMAIN, MODEL_FRANKEVER_WATER_VALVE +from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -23,7 +23,6 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from . import ( - MOCK_MAC, init_integration, mutate_rpc_device_status, patch_platforms, @@ -154,51 +153,6 @@ async def test_rpc_button_reauth_error( assert flow["context"].get("entry_id") == entry.entry_id -@pytest.mark.parametrize( - ("gen", "old_unique_id", "new_unique_id", "migration"), - [ - (2, "123456789ABC_reboot", "123456789ABC-reboot", True), - (1, "123456789ABC_reboot", "123456789ABC-reboot", True), - (2, "123456789ABC-reboot", "123456789ABC-reboot", False), - ], -) -async def test_migrate_unique_id( - hass: HomeAssistant, - mock_block_device: Mock, - mock_rpc_device: Mock, - entity_registry: EntityRegistry, - caplog: pytest.LogCaptureFixture, - gen: int, - old_unique_id: str, - new_unique_id: str, - migration: bool, -) -> None: - """Test migration of unique_id.""" - entry = await init_integration(hass, gen, skip_setup=True) - - entity = entity_registry.async_get_or_create( - suggested_object_id="test_name_restart", - disabled_by=None, - domain=BUTTON_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - config_entry=entry, - ) - assert entity.unique_id == old_unique_id - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_entry = entity_registry.async_get("button.test_name_restart") - assert entity_entry - assert entity_entry.unique_id == new_unique_id - - assert ( - bool("Migrating unique_id for button.test_name_restart" in caplog.text) - == migration - ) - - async def test_rpc_blu_trv_button( hass: HomeAssistant, mock_blu_trv: Mock, @@ -404,90 +358,6 @@ async def test_wall_display_virtual_button( mock_rpc_device.button_trigger.assert_called_once_with(200, "single_push") -async def test_migrate_unique_id_blu_trv( - hass: HomeAssistant, - mock_blu_trv: Mock, - entity_registry: EntityRegistry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test migration of unique_id for BLU TRV button.""" - entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3, skip_setup=True) - - old_unique_id = "f8:44:77:25:f0:dd_calibrate" - - entity = entity_registry.async_get_or_create( - suggested_object_id="trv_name_calibrate", - disabled_by=None, - domain=BUTTON_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - config_entry=entry, - ) - assert entity.unique_id == old_unique_id - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_entry = entity_registry.async_get("button.trv_name_calibrate") - assert entity_entry - assert entity_entry.unique_id == "F8447725F0DD-blutrv:200-calibrate" - - assert "Migrating unique_id for button.trv_name_calibrate" in caplog.text - - -@pytest.mark.parametrize( - ("old_id", "new_id", "role"), - [ - ("button", "button_generic", None), - ("button", "button_open", "open"), - ("button", "button_close", "close"), - ], -) -async def test_migrate_unique_id_virtual_components_roles( - hass: HomeAssistant, - mock_rpc_device: Mock, - entity_registry: EntityRegistry, - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, - old_id: str, - new_id: str, - role: str | None, -) -> None: - """Test migration of unique_id for virtual components to include role.""" - entry = await init_integration( - hass, 3, model=MODEL_FRANKEVER_WATER_VALVE, skip_setup=True - ) - old_unique_id = f"{MOCK_MAC}-{old_id}:200" - new_unique_id = f"{old_unique_id}-{new_id}" - config = deepcopy(mock_rpc_device.config) - if role: - config[f"{old_id}:200"] = { - "role": role, - } - else: - config[f"{old_id}:200"] = {} - monkeypatch.setattr(mock_rpc_device, "config", config) - - entity = entity_registry.async_get_or_create( - suggested_object_id="test_name_test_button", - disabled_by=None, - domain=BUTTON_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - config_entry=entry, - ) - assert entity.unique_id == old_unique_id - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_entry = entity_registry.async_get("button.test_name_test_button") - assert entity_entry - assert entity_entry.unique_id == new_unique_id - - assert "Migrating unique_id for button.test_name_test_button" in caplog.text - - async def test_rpc_smoke_mute_alarm_button( hass: HomeAssistant, mock_rpc_device: Mock, From d0b99bc435b5045a1b2fc4701d50363f38cb14e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jul 2026 11:58:03 -0500 Subject: [PATCH 062/707] Bump habluetooth to 6.26.5 (#175595) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- tests/components/bluetooth/test_manager.py | 26 ++++++++++++++++--- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f31a6e0afef5..e525e5ae9255 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.6.4", "bluetooth-data-tools==1.29.18", "dbus-fast==5.0.22", - "habluetooth==6.26.2" + "habluetooth==6.26.5" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37e9c989b35b..1673fadae40c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ file-read-backwards==2.0.0 fnv-hash-fast==2.0.3 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 -habluetooth==6.26.2 +habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 828728189e44..7606f47553b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5 habiticalib==0.4.7 # homeassistant.components.bluetooth -habluetooth==6.26.2 +habluetooth==6.26.5 # homeassistant.components.hanna hanna-cloud==0.0.7 diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index b6f4b1f8ce1e..c8aa6610df5f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -12,6 +12,9 @@ from habluetooth import BluetoothScanningMode, HaScanner # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS + +# pylint: disable-next=no-name-in-module +from habluetooth.const import STALE_ROAM_FACTOR import pytest from homeassistant import config_entries @@ -402,9 +405,26 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) - # Should switch to hci1 since the previous advertisement is stale - # even though the signal is poor because the device is now - # likely unreachable via hci0 + # Should not roam yet: a single missed reception interval must not hand a + # stationary device to a comparable scanner before the roam gate + # (STALE_ROAM_FACTOR stale windows) + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + + (10 + TRACKER_BUFFERING_WOBBLE_SECONDS) * STALE_ROAM_FACTOR + + 1, + HCI1_SOURCE_ADDRESS, + ) + # Past the roam gate (STALE_ROAM_FACTOR stale windows): switch to hci1 + # since the previous advertisement is stale even though the signal is poor + # because the device is now likely unreachable via hci0 assert ( bluetooth.async_ble_device_from_address(hass, address) is switchbot_device_poor_signal_hci1 From 8a95ce69083359ecb4b234293e0eed367ded6521 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 4 Jul 2026 11:06:24 -0700 Subject: [PATCH 063/707] Fix remote calendar state after refreshing events (#175623) --- .../components/remote_calendar/calendar.py | 30 ++++++++-- .../remote_calendar/test_calendar.py | 59 ++++++++++++++++++- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 158c73ac4e32..7273bf345e8d 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -8,7 +8,7 @@ from ical.event import Event from ical.timeline import Timeline, materialize_timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -88,15 +88,13 @@ class RemoteCalendarEntity( return await self.hass.async_add_executor_job(events_in_range) - @override - async def async_update(self) -> None: - """Refresh the timeline. + async def _async_update_timeline(self) -> None: + """Refresh the timeline and write state. This is called when the coordinator updates. Creating the timeline may require walking through the entire calendar and handling recurring events, so it is done as a separate task without blocking the event loop. """ - await super().async_update() def _get_timeline() -> Timeline | None: """Return a materialized timeline with upcoming events.""" @@ -111,6 +109,28 @@ class RemoteCalendarEntity( self._timeline = await self.hass.async_add_executor_job(_get_timeline) + @override + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + await self._async_update_timeline() + self.async_write_ha_state() + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.coordinator.config_entry.async_create_task( + self.hass, + self._async_handle_coordinator_update(), + name="remote calendar timeline update", + ) + + async def _async_handle_coordinator_update(self) -> None: + """Refresh the timeline and write state.""" + await self._async_update_timeline() + self.async_write_ha_state() + def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index ea52d961414b..8fa54e50bfba 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -1,6 +1,6 @@ """Tests for calendar platform of Remote Calendar.""" -from datetime import datetime +from datetime import datetime, timedelta import pathlib import textwrap @@ -12,6 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import setup_integration from .conftest import ( @@ -530,3 +531,59 @@ async def test_event_edge_during_refresh_interval( assert state assert state.state == STATE_OFF assert state.attributes.get("message") == "Event Two" + + +@respx.mock +@pytest.mark.freeze_time("2026-05-18 06:00:00+00:00") +async def test_coordinator_refresh_updates_upcoming_event_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test a coordinator refresh updates the materialized upcoming event.""" + original_calendar = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Wake up + DTSTART:20260518T064000 + DTEND:20260518T065500 + END:VEVENT + END:VCALENDAR + """ + ) + updated_calendar = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Wake up + DTSTART:20260519T080000 + DTEND:20260519T081500 + END:VEVENT + END:VCALENDAR + """ + ) + route = respx.get(CALENDER_URL).mock( + side_effect=[ + Response(status_code=200, text=original_calendar), + # We currently update the calendar twice on startup, tracked + # in issue #148315 + Response(status_code=200, text=original_calendar), + Response(status_code=200, text=updated_calendar), + ] + ) + await setup_integration(hass, config_entry) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.attributes.get("start_time") == "2026-05-18 06:40:00" + + # Advance clock to trigger the next update interval + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.attributes.get("start_time") == "2026-05-19 08:00:00" + assert route.call_count == 3 From f191ca32b450ca0e0cef28b8637b11b6eb3cb015 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 4 Jul 2026 20:06:53 +0200 Subject: [PATCH 064/707] bump pyHomee to v1.4.2 (#175591) --- homeassistant/components/homee/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 54e1c3efbc88..d43c504f5018 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "silver", - "requirements": ["pyHomee==1.4.1"], + "requirements": ["pyHomee==1.4.2"], "zeroconf": [ { "name": "homee-*", diff --git a/requirements_all.txt b/requirements_all.txt index 7606f47553b3..0a0de0c68894 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1970,7 +1970,7 @@ pyEmby==1.10 pyHik==0.4.3 # homeassistant.components.homee -pyHomee==1.4.1 +pyHomee==1.4.2 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From bc2017a9b49f43474aa720c0ff5ffaed7198d847 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 4 Jul 2026 22:04:35 +0200 Subject: [PATCH 065/707] Proxmox clean up import code (#175557) --- .../components/proxmoxve/__init__.py | 130 +----------------- .../components/proxmoxve/config_flow.py | 24 ---- .../components/proxmoxve/strings.json | 22 --- .../components/proxmoxve/test_config_flow.py | 81 +---------- tests/components/proxmoxve/test_init.py | 46 +------ 5 files changed, 7 insertions(+), 296 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 2f969fae5bed..291a6c298e31 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,43 +2,17 @@ import logging -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_TOKEN, - CONF_USERNAME, - CONF_VERIFY_SSL, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import ( - config_validation as cv, - entity_registry as er, - issue_registry as ir, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .const import ( AUTH_OTHER, AUTH_PAM, AUTH_PVE, CONF_AUTH_METHOD, - CONF_CONTAINERS, - CONF_NODE, - CONF_NODES, CONF_REALM, - CONF_TOKEN_ID, - CONF_TOKEN_SECRET, - CONF_VMS, - DEFAULT_PORT, DEFAULT_REALM, - DEFAULT_VERIFY_SSL, - DOMAIN, ) from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator @@ -49,107 +23,9 @@ PLATFORMS = [ ] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required( - CONF_AUTH_METHOD, default=DEFAULT_REALM - ): cv.string, - vol.Optional(CONF_REALM, default=DEFAULT_REALM): cv.string, - vol.Optional(CONF_TOKEN, default=False): cv.boolean, - vol.Optional(CONF_TOKEN_ID): cv.string, - vol.Optional(CONF_TOKEN_SECRET): cv.string, - vol.Optional( - CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL - ): cv.boolean, - vol.Required(CONF_NODES): vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_NODE): cv.string, - vol.Optional(CONF_VMS, default=[]): [ - cv.positive_int - ], - vol.Optional(CONF_CONTAINERS, default=[]): [ - cv.positive_int - ], - } - ) - ], - ), - } - ) - ], - ) - }, - extra=vol.ALLOW_EXTRA, -) - _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Proxmox configuration from YAML.""" - if DOMAIN not in config: - return True - - hass.async_create_task(_async_setup(hass, config)) - - return True - - -async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None: - for entry_config in config[DOMAIN]: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=entry_config, - ) - if ( - result.get("type") is FlowResultType.ABORT - and result.get("reason") != "already_configured" - ): - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result.get('reason')}", - breaks_in_ha_version="2026.8.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Proxmox VE", - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2026.8.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Proxmox VE", - }, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool: """Set up a ProxmoxVE from a config entry.""" coordinator = ProxmoxCoordinator(hass, entry) diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 565c37300fe5..14c4086f6a61 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -356,30 +356,6 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN): return proxmox_nodes, errors - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle a flow initiated by configuration file.""" - self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) - - try: - proxmox_nodes = await self.hass.async_add_executor_job( - _get_nodes_data, import_data - ) - except ProxmoxConnectTimeout: - return self.async_abort(reason="connect_timeout") - except ProxmoxAuthenticationError: - return self.async_abort(reason="invalid_auth") - except ProxmoxSSLError: - return self.async_abort(reason="ssl_error") - except ProxmoxNoNodesFound: - return self.async_abort(reason="no_nodes_found") - except ProxmoxConnectionError: - return self.async_abort(reason="cannot_connect") - - return self.async_create_entry( - title=import_data[CONF_HOST], - data={**import_data, CONF_NODES: proxmox_nodes}, - ) - def _get_auth_schema( self, data: Mapping[str, Any], diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index a92e6ef4506f..62316d3508e0 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -342,28 +342,6 @@ "message": "A timeout occurred while trying to connect to the Proxmox VE instance." } }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - }, - "deprecated_yaml_import_issue_connect_timeout": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "The {integration_title} YAML configuration is being removed" - }, - "deprecated_yaml_import_issue_invalid_auth": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - }, - "deprecated_yaml_import_issue_no_nodes_found": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, no active nodes were found on the Proxmox VE server. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - }, - "deprecated_yaml_import_issue_ssl_error": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - } - }, "selector": { "auth_method": { "options": { diff --git a/tests/components/proxmoxve/test_config_flow.py b/tests/components/proxmoxve/test_config_flow.py index c6814ca95717..981332352929 100644 --- a/tests/components/proxmoxve/test_config_flow.py +++ b/tests/components/proxmoxve/test_config_flow.py @@ -9,7 +9,7 @@ import pytest import requests from requests.exceptions import ConnectTimeout, SSLError -from homeassistant.components.proxmoxve import CONF_AUTH_METHOD, CONF_HOST, CONF_REALM +from homeassistant.components.proxmoxve import CONF_AUTH_METHOD, CONF_REALM from homeassistant.components.proxmoxve.const import ( CONF_NODE, CONF_NODES, @@ -17,8 +17,9 @@ from homeassistant.components.proxmoxve.const import ( CONF_TOKEN_SECRET, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_TOKEN, @@ -424,82 +425,6 @@ async def test_duplicate_entry( assert result["reason"] == "already_configured" -async def test_import_flow( - hass: HomeAssistant, - mock_setup_entry: MagicMock, - mock_proxmox_client: MagicMock, -) -> None: - """Test importing from YAML creates a config entry and sets it up.""" - MOCK_IMPORT_CONFIG = { - DOMAIN: { - **MOCK_USER_STEP, - **MOCK_USER_AUTH_STEP_PASSWORD, - **MOCK_USER_SETUP, - } - } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_CONFIG[DOMAIN] - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "127.0.0.1" - assert result["data"][CONF_HOST] == "127.0.0.1" - assert len(mock_setup_entry.mock_calls) == 1 - - assert result["result"].state is ConfigEntryState.LOADED - - -@pytest.mark.parametrize( - ("exception", "reason"), - [ - ( - AuthenticationError("Invalid credentials"), - "invalid_auth", - ), - ( - SSLError("SSL handshake failed"), - "ssl_error", - ), - ( - ConnectTimeout("Connection timed out"), - "connect_timeout", - ), - ( - ResourceException("404", "status_message", "content"), - "no_nodes_found", - ), - ( - requests.exceptions.ConnectionError("Connection error"), - "cannot_connect", - ), - ], -) -async def test_import_flow_exceptions( - hass: HomeAssistant, - mock_setup_entry: MagicMock, - mock_proxmox_client: MagicMock, - exception: Exception, - reason: str, -) -> None: - """Test importing from YAML creates a config entry and sets it up.""" - MOCK_IMPORT_CONFIG = { - DOMAIN: { - **MOCK_USER_STEP, - **MOCK_USER_AUTH_STEP_PASSWORD, - **MOCK_USER_SETUP, - } - } - mock_proxmox_client.nodes.get.side_effect = exception - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_CONFIG[DOMAIN] - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - assert len(mock_setup_entry.mock_calls) == 0 - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - - def sanitize_config_entry(data: dict[str, Any]) -> dict[str, Any]: """Sanitize config entry data by removing unused auth keys.""" # Ignore unused keys (i.e. when switching from password to token or vice versa) diff --git a/tests/components/proxmoxve/test_init.py b/tests/components/proxmoxve/test_init.py index 7c3aa0fac2af..d55afccaa03c 100644 --- a/tests/components/proxmoxve/test_init.py +++ b/tests/components/proxmoxve/test_init.py @@ -11,11 +11,7 @@ from requests.exceptions import ConnectTimeout, SSLError from homeassistant.components.proxmoxve.const import ( AUTH_PAM, CONF_AUTH_METHOD, - CONF_CONTAINERS, - CONF_NODE, - CONF_NODES, CONF_REALM, - CONF_VMS, DOMAIN, ) from homeassistant.components.proxmoxve.coordinator import ( @@ -32,54 +28,14 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from . import setup_integration from tests.common import MockConfigEntry, async_load_json_array_fixture -@pytest.mark.usefixtures("mock_setup_entry") -async def test_config_import( - hass: HomeAssistant, - mock_proxmox_client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test sensor initialization.""" - await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: [ - { - CONF_HOST: "127.0.0.1", - CONF_PORT: 8006, - CONF_REALM: "pam", - CONF_USERNAME: "test_user@pam", - CONF_PASSWORD: "test_password", - CONF_VERIFY_SSL: True, - CONF_NODES: [ - { - CONF_NODE: "pve1", - CONF_VMS: [100, 101], - CONF_CONTAINERS: [200, 201], - }, - ], - } - ] - }, - ) - - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml") in issue_registry.issues - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - @pytest.mark.parametrize( ("exception", "expected_state", "target"), [ From 721c7c2576d95f43699ec9f2e319954f8e2298a5 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:09:30 -0600 Subject: [PATCH 066/707] Bump py_ccm15 to 1.1.1 (#175203) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/ccm15/climate.py | 18 ++++++++++++------ homeassistant/components/ccm15/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index 23315a57c662..c789884d5b4c 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -160,26 +160,32 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + data = self.data + assert data is not None await self.coordinator.async_set_temperature( - self._ac_index, self.data, temperature, kwargs.get(ATTR_HVAC_MODE) + self._ac_index, data, temperature, kwargs.get(ATTR_HVAC_MODE) ) @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the hvac mode.""" - await self.coordinator.async_set_hvac_mode(self._ac_index, self.data, hvac_mode) + data = self.data + assert data is not None + await self.coordinator.async_set_hvac_mode(self._ac_index, data, hvac_mode) @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the fan mode.""" - await self.coordinator.async_set_fan_mode(self._ac_index, self.data, fan_mode) + data = self.data + assert data is not None + await self.coordinator.async_set_fan_mode(self._ac_index, data, fan_mode) @override async def async_set_swing_mode(self, swing_mode: str) -> None: """Set the swing mode.""" - await self.coordinator.async_set_swing_mode( - self._ac_index, self.data, swing_mode - ) + data = self.data + assert data is not None + await self.coordinator.async_set_swing_mode(self._ac_index, data, swing_mode) @override async def async_turn_off(self) -> None: diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json index 53948217186b..4f97fda1c1fb 100644 --- a/homeassistant/components/ccm15/manifest.json +++ b/homeassistant/components/ccm15/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["py_ccm15==1.0.0"] + "requirements": ["py_ccm15==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a0de0c68894..ac17b8a5ae8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1988,7 +1988,7 @@ pyW215==0.8.0 pyW800rf32==0.4 # homeassistant.components.ccm15 -py_ccm15==1.0.0 +py_ccm15==1.1.1 # homeassistant.components.html5 py_vapid==1.9.4 From 677486cd62c5e0be8f587bf4af85b634f5436d56 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:14:54 +0200 Subject: [PATCH 067/707] Bump uiprotect to 15.4.1 (#175540) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 3161443e367a..99ce3a71ec29 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.4.0"] + "requirements": ["uiprotect==15.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac17b8a5ae8f..8dd94516aab3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3237,7 +3237,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.4.0 +uiprotect==15.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From 1910dc7e93c3f13db50b118247aa9621c7b4aa2c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 4 Jul 2026 22:25:04 +0200 Subject: [PATCH 068/707] Move Google Assistant services to async_setup (#175483) --- .../components/google_assistant/__init__.py | 31 +++------------- .../components/google_assistant/services.py | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/google_assistant/services.py diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 817467828325..3c87085929d2 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -1,13 +1,11 @@ """Support for Actions on Google Assistant Smart Home Control.""" # pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern -import logging - import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType @@ -29,15 +27,13 @@ from .const import ( # noqa: F401 DEFAULT_EXPOSED_DOMAINS, DOMAIN, EVENT_QUERY_RECEIVED, - SERVICE_REQUEST_SYNC, SOURCE_CLOUD, ) from .http import GoogleAssistantView, GoogleConfig +from .services import async_register_services from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip -_LOGGER = logging.getLogger(__name__) - CONF_ALLOW_UNLOCK = "allow_unlock" PLATFORMS = [Platform.BUTTON] @@ -105,6 +101,9 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: hass.data[DOMAIN] = {} hass.data[DOMAIN][DATA_CONFIG] = yaml_config[DOMAIN] + if CONF_SERVICE_ACCOUNT in yaml_config[DOMAIN]: + async_register_services(hass) + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -149,26 +148,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo if google_config.should_report_state: google_config.async_enable_report_state() - async def request_sync_service_handler(call: ServiceCall) -> None: - """Handle request sync service calls.""" - agent_user_id = call.data.get("agent_user_id") or call.context.user_id - - if agent_user_id is None: - _LOGGER.warning( - "No agent_user_id supplied for request_sync. Call as a user or pass in" - " user id as agent_user_id" - ) - return - - await google_config.async_sync_entities(agent_user_id) - - # Register service only if key is provided - if CONF_SERVICE_ACCOUNT in config: - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/google_assistant/services.py b/homeassistant/components/google_assistant/services.py new file mode 100644 index 000000000000..2c4391558e5d --- /dev/null +++ b/homeassistant/components/google_assistant/services.py @@ -0,0 +1,37 @@ +"""Support for Google Assistant services.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_REQUEST_SYNC +from .http import GoogleConfig + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_services(hass: HomeAssistant) -> None: + """Register Google Assistant services.""" + + async def request_sync_service_handler(call: ServiceCall) -> None: + """Handle request sync service calls.""" + agent_user_id = call.data.get("agent_user_id") or call.context.user_id + + if agent_user_id is None: + _LOGGER.warning( + "No agent_user_id supplied for request_sync. Call as a user or pass in" + " user id as agent_user_id" + ) + return + + entry: ConfigEntry[GoogleConfig] = service.async_get_config_entry( + hass, DOMAIN, None + ) + await entry.runtime_data.async_sync_entities(agent_user_id) + + hass.services.async_register( + DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler + ) From fa3ededf3c10649d9be2bc2761d1f047644de5a9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 00:32:44 +0200 Subject: [PATCH 069/707] Trim cached websocket event payload once per event instead of per subscriber (#175569) Co-authored-by: Claude --- .../components/websocket_api/messages.py | 18 ++++++++++-------- .../components/websocket_api/test_messages.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 13f856e66674..a6d3b54f4271 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -131,7 +131,7 @@ def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: """ return b"".join( ( - _partial_cached_event_message(event)[:-1], + _partial_cached_event_message(event), b',"id":', message_id_as_bytes, b"}", @@ -143,13 +143,14 @@ def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: def _partial_cached_event_message(event: Event) -> bytes: """Cache and serialize the event to json. - The message is constructed without the id which appended - in cached_event_message. + The message is cached without the trailing "}" and without the id, both of + which are appended in cached_event_message. Trimming here means the slice + happens once per event instead of once per subscriber. """ return ( _message_to_json_bytes_or_none({"type": "event", "event": event.json_fragment}) or INVALID_JSON_PARTIAL_MESSAGE - ) + )[:-1] def cached_state_diff_message( @@ -165,7 +166,7 @@ def cached_state_diff_message( """ return b"".join( ( - _partial_cached_state_diff_message(event)[:-1], + _partial_cached_state_diff_message(event), b',"id":', message_id_as_bytes, b"}", @@ -177,15 +178,16 @@ def cached_state_diff_message( def _partial_cached_state_diff_message(event: Event[EventStateChangedData]) -> bytes: """Cache and serialize the event to json. - The message is constructed without the id which - will be appended in cached_state_diff_message + The message is cached without the trailing "}" and without the id, both of + which are appended in cached_state_diff_message. Trimming here means the + slice happens once per event instead of once per subscriber. """ return ( _message_to_json_bytes_or_none( {"type": "event", "event": _state_diff_event(event)} ) or INVALID_JSON_PARTIAL_MESSAGE - ) + )[:-1] def _state_diff_event( diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 4632544e8d27..5615faf0fdf5 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -10,6 +10,7 @@ from homeassistant.components.websocket_api.messages import ( ) from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Context, Event, HomeAssistant, State, callback +from homeassistant.util.json import json_loads from tests.common import async_capture_events @@ -52,6 +53,19 @@ async def test_cached_event_message(hass: HomeAssistant) -> None: assert cache_info.currsize == 2 +async def test_cached_event_message_is_valid_json(hass: HomeAssistant) -> None: + """Test the cached event message is valid JSON with the id appended.""" + events = async_capture_events(hass, EVENT_STATE_CHANGED) + hass.states.async_set("light.window", "on") + await hass.async_block_till_done() + + parsed = json_loads(cached_event_message(b"2", events[0])) + + assert parsed["id"] == 2 + assert parsed["type"] == "event" + assert parsed["event"]["event_type"] == EVENT_STATE_CHANGED + + async def test_cached_event_message_with_different_idens(hass: HomeAssistant) -> None: """Test that we cache event messages when the subscrition idens differ.""" From 7dc93c57e4faaa546838feb43cc29e40d3feefd2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 00:33:07 +0200 Subject: [PATCH 070/707] Check supervisor Unix socket once per request, cheapest condition first (#175568) Co-authored-by: Claude --- homeassistant/components/http/const.py | 31 ++++++++++----- tests/components/http/test_const.py | 52 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 tests/components/http/test_const.py diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index c89751a62aff..092487101572 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -10,15 +10,28 @@ DOMAIN: Final = "http" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" +KEY_SUPERVISOR_UNIX_SOCKET: Final = "ha_supervisor_unix_socket" def is_supervisor_unix_socket_request(request: Request) -> bool: - """Check if request arrived over the Supervisor Unix socket.""" - if (transport := request.transport) is None: - return False - if (http := request.app[KEY_HASS].http) is None or ( - supervisor_path := http.supervisor_unix_socket_path - ) is None: - return False - sockname: str | None = transport.get_extra_info("sockname") - return sockname == str(supervisor_path) + """Check if request arrived over the Supervisor Unix socket. + + The result is cached on the request since it is checked by both the ban + and auth middlewares. + """ + cached: bool | None = request.get(KEY_SUPERVISOR_UNIX_SOCKET) + if cached is not None: + return cached + # Cheapest check first: without a configured socket path this can never be + # a Supervisor Unix socket request, so we avoid probing the transport. + if ( + (http := request.app[KEY_HASS].http) is None + or (supervisor_path := http.supervisor_unix_socket_path) is None + or (transport := request.transport) is None + ): + result = False + else: + sockname: str | None = transport.get_extra_info("sockname") + result = sockname == str(supervisor_path) + request[KEY_SUPERVISOR_UNIX_SOCKET] = result + return result diff --git a/tests/components/http/test_const.py b/tests/components/http/test_const.py new file mode 100644 index 000000000000..c921b18cbab2 --- /dev/null +++ b/tests/components/http/test_const.py @@ -0,0 +1,52 @@ +"""Tests for the HTTP const helpers.""" + +from pathlib import Path +from unittest.mock import MagicMock + +from aiohttp import web +from aiohttp.test_utils import make_mocked_request + +from homeassistant.components.http.const import ( + KEY_SUPERVISOR_UNIX_SOCKET, + is_supervisor_unix_socket_request, +) +from homeassistant.helpers.http import KEY_HASS + + +def _make_request(supervisor_path: Path | None, sockname: str | None) -> web.Request: + """Build a mocked request with the given supervisor socket configuration.""" + app = web.Application() + hass = MagicMock() + hass.http.supervisor_unix_socket_path = supervisor_path + app[KEY_HASS] = hass + transport = MagicMock() + transport.get_extra_info.return_value = sockname + return make_mocked_request("GET", "/", app=app, transport=transport) + + +def test_supervisor_unix_socket_request_matches() -> None: + """Test a request over the Supervisor Unix socket is detected.""" + path = Path("/run/supervisor.sock") + request = _make_request(path, str(path)) + assert is_supervisor_unix_socket_request(request) is True + + +def test_supervisor_unix_socket_request_no_path_skips_transport() -> None: + """Test the transport is not probed when no socket path is configured.""" + request = _make_request(None, "/run/supervisor.sock") + transport = request.transport + transport.get_extra_info.reset_mock() + assert is_supervisor_unix_socket_request(request) is False + transport.get_extra_info.assert_not_called() + + +def test_supervisor_unix_socket_request_is_cached() -> None: + """Test the result is computed once and cached on the request.""" + path = Path("/run/supervisor.sock") + request = _make_request(path, str(path)) + transport = request.transport + transport.get_extra_info.reset_mock() + assert is_supervisor_unix_socket_request(request) is True + assert is_supervisor_unix_socket_request(request) is True + transport.get_extra_info.assert_called_once_with("sockname") + assert request[KEY_SUPERVISOR_UNIX_SOCKET] is True From 984dcf1f832c396b09d9aeaee81770d9fff3e6a9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 01:50:25 +0200 Subject: [PATCH 071/707] Add humidifier LLM tools platform (#175522) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/humidifier/llm.py | 35 +++++++++++++ tests/components/humidifier/test_llm.py | 59 ++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 homeassistant/components/humidifier/llm.py create mode 100644 tests/components/humidifier/test_llm.py diff --git a/homeassistant/components/humidifier/llm.py b/homeassistant/components/humidifier/llm.py new file mode 100644 index 000000000000..d799345b7e76 --- /dev/null +++ b/homeassistant/components/humidifier/llm.py @@ -0,0 +1,35 @@ +"""LLM tools for the humidifier integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_HUMIDITY, INTENT_MODE + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_MODE, INTENT_HUMIDITY) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/humidifier/test_llm.py b/tests/components/humidifier/test_llm.py new file mode 100644 index 000000000000..e83d932bedbd --- /dev/null +++ b/tests/components/humidifier/test_llm.py @@ -0,0 +1,59 @@ +"""Tests for the humidifier LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.humidifier import llm as humidifier_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "humidifier.test" +INTENTS = {"HassHumidifierMode", "HassHumidifierSetpoint"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a humidifier entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "humidifier", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test humidifier"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the humidifier platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed humidifier entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no humidifier entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert humidifier_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert humidifier_llm.async_get_tools(hass, _llm_context(), "other") is None From ba3e63546cc60aa0d134477dc6dc7464722d55ae Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 01:54:52 +0200 Subject: [PATCH 072/707] Add script LLM tools platform (#175514) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/script/llm.py | 53 ++++++++++++++++ tests/components/script/test_llm.py | 86 ++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 homeassistant/components/script/llm.py create mode 100644 tests/components/script/test_llm.py diff --git a/homeassistant/components/script/llm.py b/homeassistant/components/script/llm.py new file mode 100644 index 000000000000..e739d979a7c6 --- /dev/null +++ b/homeassistant/components/script/llm.py @@ -0,0 +1,53 @@ +"""LLM tools for the script integration.""" + +from operator import attrgetter + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.llm import LLM_API_ASSIST, ActionTool, LLMContext, Tool + +from .const import DOMAIN + + +class ScriptTool(ActionTool): + """LLM Tool representing a Script.""" + + def __init__( + self, + hass: HomeAssistant, + script_entity_id: str, + ) -> None: + """Init the class.""" + script_name = split_entity_id(script_entity_id)[1] + + action = script_name + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(script_entity_id) + if entity_entry and entity_entry.unique_id: + action = entity_entry.unique_id + + super().__init__(hass, DOMAIN, action) + + self.name = script_name + if self.name[0].isdigit(): + self.name = "_" + self.name + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return a script LLM tool for each exposed script.""" + if api_id != LLM_API_ASSIST: + return None + + tools: list[Tool] = [ + ScriptTool(hass, state.entity_id) + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")) + if async_should_expose(hass, llm_context.assistant, state.entity_id) + ] + if not tools: + return None + return LLMTools(tools=tools) diff --git a/tests/components/script/test_llm.py b/tests/components/script/test_llm.py new file mode 100644 index 000000000000..2783bdfcf0d7 --- /dev/null +++ b/tests/components/script/test_llm.py @@ -0,0 +1,86 @@ +"""Tests for the script LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.script import llm as script_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "script.test_script" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a script.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "llm", {}) + assert await async_setup_component( + hass, + "script", + { + "script": { + "test_script": { + "alias": "test script", + "description": "This is a test script", + "sequence": [ + {"variables": {"result": {"drinks": 2}}}, + {"stop": True, "response_variable": "result"}, + ], + "fields": { + "beer": {"description": "Number of beers", "required": True}, + }, + }, + "unexposed_script": {"sequence": []}, + } + }, + ) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_script_tool_only_exposed(hass: HomeAssistant) -> None: + """Test only exposed scripts get a tool.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + names = [tool.name for tool in result.tools] + assert "test_script" in names + assert "unexposed_script" not in names + + +async def test_script_tool_not_exposed(hass: HomeAssistant) -> None: + """Test no script tool is offered when the script is not exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "test_script" not in [tool.name for tool in result.tools] + assert script_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert script_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_script_tool_call(hass: HomeAssistant) -> None: + """Test calling the exposed script through its tool.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "test_script") + + response = await tool.async_call( + hass, llm.ToolInput("test_script", {"beer": 1}), llm_context + ) + assert response == {"success": True, "result": {"drinks": 2}} From 9538aa5bcc00ede60b80e0a85fe0fe3d3c5c243f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 03:38:57 +0200 Subject: [PATCH 073/707] Add homeassistant LLM tools platform for GetLiveContext (#175516) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/homeassistant/llm.py | 294 ++++++++++++++ tests/components/homeassistant/test_llm.py | 379 ++++++++++++++++++ 2 files changed, 673 insertions(+) create mode 100644 homeassistant/components/homeassistant/llm.py create mode 100644 tests/components/homeassistant/test_llm.py diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py new file mode 100644 index 000000000000..a5608ae274e7 --- /dev/null +++ b/homeassistant/components/homeassistant/llm.py @@ -0,0 +1,294 @@ +"""LLM tools for the homeassistant integration.""" + +from decimal import Decimal +from enum import Enum +from operator import attrgetter +from typing import Any, override + +import voluptuous as vol + +from homeassistant.components.llm import LLMTools +from homeassistant.components.sensor import async_rounded_state +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + entity_registry as er, + intent, +) +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + NO_ENTITIES_PROMPT, + LLMContext, + Tool, + ToolInput, +) +from homeassistant.util import dt as dt_util, yaml as yaml_util +from homeassistant.util.json import JsonObjectType + +from .exposed_entities import async_should_expose + +# Domains bucketed out of the exposed-entity overview. +CALENDAR_DOMAIN = "calendar" +SCRIPT_DOMAIN = "script" + + +@callback +def async_get_exposed_entities( + hass: HomeAssistant, + assistant: str, + include_state: bool = True, +) -> dict[str, dict[str, Any]]: + """Get exposed entities, ignoring calendars and scripts.""" + area_registry = ar.async_get(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + interesting_attributes = { + "temperature", + "current_temperature", + "temperature_unit", + "brightness", + "humidity", + "unit_of_measurement", + "device_class", + "current_position", + "percentage", + "volume_level", + "media_title", + "media_artist", + "media_album_name", + } + + entities: dict[str, dict[str, Any]] = {} + + for state in sorted(hass.states.async_all(), key=attrgetter("name")): + if not async_should_expose(hass, assistant, state.entity_id): + continue + + # Calendars and scripts have their own tools; skip them here. + if state.domain in (CALENDAR_DOMAIN, SCRIPT_DOMAIN): + continue + + entity_entry = entity_registry.async_get(state.entity_id) + device_entry = ( + device_registry.async_get(entity_entry.device_id) + if entity_entry is not None and entity_entry.device_id is not None + else None + ) + names = intent.async_get_entity_aliases(hass, entity_entry, state=state) + area_names = [] + + if entity_entry is not None: + if ( + entity_entry.area_id is not None + and (area_entry := area_registry.async_get_area(entity_entry.area_id)) + is not None + ): + # Entity is in area + area_names.append(area_entry.name) + area_names.extend(sorted(area_entry.aliases)) + elif device_entry is not None: + # Check device area + if ( + device_entry.area_id is not None + and ( + area_entry := area_registry.async_get_area(device_entry.area_id) + ) + is not None + ): + area_names.append(area_entry.name) + area_names.extend(sorted(area_entry.aliases)) + + info: dict[str, Any] = { + "names": ", ".join(names), + "domain": state.domain, + } + + if include_state: + info["state"] = state.state + + # Format numeric states with configured display precision + if state.domain == "sensor": + info["state"] = async_rounded_state(hass, state.entity_id, state) + + # Convert timestamp device_class states from UTC to local time + if state.attributes.get("device_class") == "timestamp" and state.state: + if (parsed_utc := dt_util.parse_datetime(state.state)) is not None: + info["state"] = dt_util.as_local(parsed_utc).isoformat() + + if area_names: + info["areas"] = ", ".join(area_names) + + if include_state and ( + attributes := { + str(attr_name): ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) + for attr_name, attr_value in state.attributes.items() + if attr_name in interesting_attributes + } + ): + info["attributes"] = attributes + + entities[state.entity_id] = info + + return entities + + +def _live_context_match_error( + match_result: intent.MatchTargetsResult, + name_filter: str | None, + area_filter: str | None, + domain_filter: list[str] | None, +) -> str: + """Build an actionable error message for a failed GetLiveContext match.""" + reason = match_result.no_match_reason + if reason is intent.MatchFailedReason.INVALID_AREA: + return f"Area '{match_result.no_match_name}' does not exist" + if reason is intent.MatchFailedReason.NAME: + return f"No exposed entities matched name '{name_filter}'" + if reason is intent.MatchFailedReason.AREA: + return f"No exposed entities found in area '{area_filter}'" + if reason is intent.MatchFailedReason.DOMAIN: + domains = ", ".join(domain_filter) if domain_filter else "" + return f"No exposed entities found in domain(s): {domains}" + return "No entities matched the provided filter" + + +class GetLiveContextTool(Tool): + """Tool for getting the current state of exposed entities. + + This returns state for all entities that have been exposed to + the assistant. This is different than the GetState intent, which + returns state for entities based on intent parameters. + """ + + name = "GetLiveContext" + description = ( + "Provides real-time information about the" + " CURRENT state, value, or mode of devices," + " sensors, entities, or areas. " + "Use this tool for: " + "1. Answering questions about current" + " conditions (e.g., 'Is the light on?'). " + "2. As the first step in conditional actions" + " (e.g., 'If the weather is rainy, turn off" + " sprinklers' requires checking the weather" + " first). " + "You may filter for devices by name, domain," + " and area, including combining those" + " filters. " + "Prefer filtering by domain when searching" + " for multiple devices of the same type." + ) + parameters = vol.Schema( + { + vol.Optional( + "name", + description="Filter entities by name or alias (case-insensitive).", + ): cv.string, + vol.Optional( + "domain", + description=( + "Filter entities by domain" + " (e.g. 'light', 'sensor')." + " Accepts a single domain or a list." + ), + ): vol.Any(cv.string, [cv.string]), + vol.Optional( + "area", + description="Filter entities by area name or alias (case-insensitive).", + ): cv.string, + } + ) + + @override + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current state of exposed entities.""" + args = self.parameters(tool_input.tool_args) + exposed_entities = async_get_exposed_entities(hass, llm_context.assistant) + + if not exposed_entities: + return {"success": False, "error": NO_ENTITIES_PROMPT} + + name_filter = args.get("name") + area_filter = args.get("area") + domain_filter = args.get("domain") + + if isinstance(domain_filter, str): + domain_filter = [domain_filter] + + if domain_filter is not None: + domain_filter = [ + normalized_domain + for domain in domain_filter + if (normalized_domain := domain.strip().lower()) + ] + + if name_filter or area_filter or domain_filter: + exposed_states = [ + state + for entity_id in exposed_entities + if (state := hass.states.get(entity_id)) is not None + ] + match_result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=name_filter, + area_name=area_filter, + domains=domain_filter, + # This tool only returns context, so multiple entities + # sharing a name (e.g. "AC" in two areas) should all be + # returned rather than failing as an ambiguous match. + allow_duplicate_names=True, + ), + states=exposed_states, + ) + + if not match_result.is_match: + return { + "success": False, + "error": _live_context_match_error( + match_result, name_filter, area_filter, domain_filter + ), + } + + matched_ids = {state.entity_id for state in match_result.states} + entities = [ + info + for entity_id, info in exposed_entities.items() + if entity_id in matched_ids + ] + else: + entities = list(exposed_entities.values()) + + prompt = [ + "Live Context: An overview of the areas" + " and the devices in this smart home:", + yaml_util.dump(entities), + ] + return { + "success": True, + "result": "\n".join(prompt), + } + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the GetLiveContext tool. + + The tool is always offered; it reports when nothing is exposed at call time. + """ + if api_id != LLM_API_ASSIST: + return None + return LLMTools(tools=[GetLiveContextTool()]) diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py new file mode 100644 index 000000000000..2d888f08c6ed --- /dev/null +++ b/tests/components/homeassistant/test_llm.py @@ -0,0 +1,379 @@ +"""Tests for the homeassistant LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant import llm as ha_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.homeassistant.llm import async_get_exposed_entities +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + llm, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +ENTITY_ID = "light.kitchen" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose an entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Kitchen Light"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_live_context_always_offered(hass: HomeAssistant) -> None: + """Test GetLiveContext is offered even when nothing is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "GetLiveContext" in [tool.name for tool in result.tools] + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert ha_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None: + """Test GetLiveContext reports an error when nothing is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "GetLiveContext") + + response = await tool.async_call( + hass, llm.ToolInput("GetLiveContext", {}), llm_context + ) + assert response == {"success": False, "error": llm.NO_ENTITIES_PROMPT} + + +async def test_get_live_context_tool(hass: HomeAssistant) -> None: + """Test GetLiveContext returns exposed entity state.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next((tool for tool in result.tools if tool.name == "GetLiveContext"), None) + assert tool is not None + + response = await tool.async_call( + hass, llm.ToolInput("GetLiveContext", {}), llm_context + ) + assert response["success"] is True + assert "Kitchen Light" in response["result"] + + +async def test_get_exposed_entities_timestamp_conversion(hass: HomeAssistant) -> None: + """Test that async_get_exposed_entities converts timestamp states to local time.""" + # Set the timezone to something other than UTC to ensure conversion is tested + await hass.config.async_set_time_zone("America/New_York") + + # Set up a timestamp sensor with UTC time + hass.states.async_set( + "sensor.test_timestamp", + "2024-01-15T10:30:00+00:00", + {"device_class": "timestamp", "friendly_name": "Test Timestamp"}, + ) + # Also test with a non-timestamp sensor to ensure it's not affected + hass.states.async_set( + "sensor.regular_sensor", + "2024-01-15T10:30:00+00:00", + {"friendly_name": "Regular Sensor"}, # No device_class + ) + # And test with invalid/empty timestamp + hass.states.async_set( + "sensor.invalid_timestamp", + "not-a-timestamp", + {"device_class": "timestamp", "friendly_name": "Invalid Timestamp"}, + ) + hass.states.async_set( + "sensor.empty_timestamp", + "", + {"device_class": "timestamp", "friendly_name": "Empty Timestamp"}, + ) + for entity_id in ( + "sensor.test_timestamp", + "sensor.regular_sensor", + "sensor.invalid_timestamp", + "sensor.empty_timestamp", + ): + async_expose_entity(hass, "conversation", entity_id, True) + + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + + # Timestamp state is converted to local time + assert exposed["sensor.test_timestamp"]["state"] == "2024-01-15T05:30:00-05:00" + # Regular sensor without device_class keeps its original value + assert exposed["sensor.regular_sensor"]["state"] == "2024-01-15T10:30:00+00:00" + # Invalid timestamp remains as-is + assert exposed["sensor.invalid_timestamp"]["state"] == "not-a-timestamp" + # Empty timestamp remains empty + assert exposed["sensor.empty_timestamp"]["state"] == "" + + # With include_state=False no state (and therefore no conversion) is included + exposed_no_state = async_get_exposed_entities( + hass, "conversation", include_state=False + ) + assert "state" not in exposed_no_state["sensor.test_timestamp"] + + +async def test_get_live_context_tool_filter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test the filter parameters of the GetLiveContext tool.""" + # The autouse fixture exposes light.kitchen; drop it for a clean entity set. + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert await async_setup_component(hass, "intent", {}) + + llm_context = _llm_context() + + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + + office = area_registry.async_create("Office") + area_registry.async_update(office.id, aliases={"Workspace"}) + area_registry.async_create("Kitchen") + + office_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "office-1")}, + suggested_area="Office", + ) + kitchen_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "kitchen-1")}, + suggested_area="Kitchen", + ) + + office_light = entity_registry.async_get_or_create( + "light", + "test", + "office_light", + original_name="Office Light", + device_id=office_device.id, + suggested_object_id="office_light", + ) + kitchen_light = entity_registry.async_get_or_create( + "light", + "test", + "kitchen_light", + original_name="Kitchen Light", + device_id=kitchen_device.id, + suggested_object_id="kitchen_light", + ) + office_switch = entity_registry.async_get_or_create( + "switch", + "test", + "office_switch", + original_name="Office Switch", + device_id=office_device.id, + suggested_object_id="office_switch", + ) + front_door = entity_registry.async_get_or_create( + "lock", + "test", + "front_door", + original_name="Front Door", + suggested_object_id="front_door", + ) + # Two entities sharing the same name in different areas + office_ac = entity_registry.async_get_or_create( + "climate", + "test", + "office_ac", + original_name="AC", + device_id=office_device.id, + suggested_object_id="office_ac", + ) + kitchen_ac = entity_registry.async_get_or_create( + "climate", + "test", + "kitchen_ac", + original_name="AC", + device_id=kitchen_device.id, + suggested_object_id="kitchen_ac", + ) + entity_registry.async_update_entity( + kitchen_light.entity_id, aliases=[er.COMPUTED_NAME, "Cooking Lamp"] + ) + + for entity_id in ( + office_light.entity_id, + kitchen_light.entity_id, + office_switch.entity_id, + front_door.entity_id, + office_ac.entity_id, + kitchen_ac.entity_id, + ): + async_expose_entity(hass, "conversation", entity_id, True) + + hass.states.async_set(office_light.entity_id, "on") + hass.states.async_set(kitchen_light.entity_id, "off") + hass.states.async_set(office_switch.entity_id, "on") + hass.states.async_set(front_door.entity_id, "locked") + hass.states.async_set(office_ac.entity_id, "cool") + hass.states.async_set(kitchen_ac.entity_id, "heat") + + await hass.async_block_till_done() + tools = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(t for t in tools.tools if t.name == "GetLiveContext") + + async def _get_live_context(tool_args: dict) -> dict: + return await tool.async_call( + hass, llm.ToolInput("GetLiveContext", tool_args), llm_context + ) + + # Filter by area and domain (example 1) + result = await _get_live_context({"area": "Office", "domain": "light"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + assert "Front Door" not in result["result"] + + # Filter by name (example 2) + result = await _get_live_context({"name": "Front Door"}) + assert result["success"] is True + assert "Front Door" in result["result"] + assert "Office Light" not in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + + # Name filter is case insensitive + result = await _get_live_context({"name": "front door"}) + assert result["success"] is True + assert "Front Door" in result["result"] + + # Area filter matches area aliases + result = await _get_live_context({"area": "workspace"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Front Door" not in result["result"] + + # Domain filter accepts a list + result = await _get_live_context({"domain": ["switch", "lock"]}) + assert result["success"] is True + assert "Office Switch" in result["result"] + assert "Front Door" in result["result"] + assert "Office Light" not in result["result"] + assert "Kitchen Light" not in result["result"] + + # Domain filter is case insensitive + result = await _get_live_context({"domain": "Light"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" in result["result"] + assert "Office Switch" not in result["result"] + assert "Front Door" not in result["result"] + + # No filters returns all exposed entities + result = await _get_live_context({}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Front Door" in result["result"] + + # Filter that matches nothing returns a descriptive error + result = await _get_live_context({"name": "Does Not Exist"}) + assert result == { + "success": False, + "error": "No exposed entities matched name 'Does Not Exist'", + } + + # Name filter strips surrounding whitespace + result = await _get_live_context({"name": " Front Door "}) + assert result["success"] is True + assert "Front Door" in result["result"] + + # Area filter strips surrounding whitespace + result = await _get_live_context({"area": " Office "}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + + # Name filter accepts entity_id + result = await _get_live_context({"name": office_light.entity_id}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + + # Area filter accepts area_id + result = await _get_live_context({"area": office.id}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Front Door" not in result["result"] + + # Name filter matches entity aliases + result = await _get_live_context({"name": "cooking lamp"}) + assert result["success"] is True + assert "Kitchen Light" in result["result"] + assert "Office Light" not in result["result"] + + # Combining name + area narrows the result + result = await _get_live_context({"name": "Office Light", "area": "Office"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" not in result["result"] + + # Combining name + area returns the failing constraint in the error + result = await _get_live_context({"name": "Office Light", "area": "Kitchen"}) + assert result == { + "success": False, + "error": "No exposed entities found in area 'Kitchen'", + } + + # Unknown area distinguishes "invalid area" from "no entities in area" + result = await _get_live_context({"area": "Garage"}) + assert result == { + "success": False, + "error": "Area 'Garage' does not exist", + } + + # Unknown domain reports which domain(s) failed + result = await _get_live_context({"domain": "fan"}) + assert result == { + "success": False, + "error": "No exposed entities found in domain(s): fan", + } + + # Entities sharing a name are all returned rather than failing as an + # ambiguous match, since this tool only returns context. + result = await _get_live_context({"name": "AC"}) + assert result["success"] is True + assert result["result"].count("domain: climate") == 2 + assert "Office" in result["result"] + assert "Kitchen" in result["result"] + + # Combining a shared name with an area narrows to the single match + result = await _get_live_context({"name": "AC", "area": "Kitchen"}) + assert result["success"] is True + assert result["result"].count("domain: climate") == 1 + assert "Kitchen" in result["result"] + assert "Office" not in result["result"] From e2508cffde2723ad92cdeb3a6a15f252f75bbcf9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 03:39:37 +0200 Subject: [PATCH 074/707] Add intent LLM tools platform (#175517) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/intent/llm.py | 68 +++++++++++++++++++++ tests/components/intent/test_llm.py | 83 ++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 homeassistant/components/intent/llm.py create mode 100644 tests/components/intent/test_llm.py diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py new file mode 100644 index 000000000000..82b122a91890 --- /dev/null +++ b/homeassistant/components/intent/llm.py @@ -0,0 +1,68 @@ +"""LLM tools for the intent integration. + +Exposes the generic, cross-domain intents owned by the intent integration +(device on/off, position, timers) as LLM tools. Domain-specific intents are +exposed by their own integration's ``llm.py`` platform. +""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .timers import async_device_supports_timers + +# Generic intents exposed as LLM tools regardless of a timer-capable device. +LLM_INTENTS = ( + intent.INTENT_TURN_ON, + intent.INTENT_TURN_OFF, + intent.INTENT_CANCEL_ALL_TIMERS, + intent.INTENT_SET_POSITION, + intent.INTENT_STOP_MOVING, +) + +# Timer intents, only exposed for a device that supports timers. +TIMER_INTENTS = ( + intent.INTENT_START_TIMER, + intent.INTENT_CANCEL_TIMER, + intent.INTENT_INCREASE_TIMER, + intent.INTENT_DECREASE_TIMER, + intent.INTENT_PAUSE_TIMER, + intent.INTENT_UNPAUSE_TIMER, + intent.INTENT_TIMER_STATUS, +) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the generic intents.""" + if api_id != LLM_API_ASSIST: + return None + + wanted = set(LLM_INTENTS) + if llm_context.device_id and async_device_supports_timers( + hass, llm_context.device_id + ): + wanted.update(TIMER_INTENTS) + + exposed_domains = { + state.domain + for state in hass.states.async_all() + if async_should_expose(hass, llm_context.assistant, state.entity_id) + } + handlers = [ + handler + for handler in intent.async_get(hass) + if handler.intent_type in wanted + and (handler.platforms is None or handler.platforms & exposed_domains) + ] + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) for handler in handlers + ] + if not tools: + return None + return LLMTools(tools=tools) diff --git a/tests/components/intent/test_llm.py b/tests/components/intent/test_llm.py new file mode 100644 index 000000000000..6db25c1b6ab1 --- /dev/null +++ b/tests/components/intent/test_llm.py @@ -0,0 +1,83 @@ +"""Tests for the intent LLM tools platform (generic intents).""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent import llm as intent_llm +from homeassistant.components.intent.timers import async_register_timer_handler +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +COVER_ENTITY_ID = "cover.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a cover.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(COVER_ENTITY_ID, "open", {"friendly_name": "Test Cover"}) + async_expose_entity(hass, "conversation", COVER_ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context(device_id: str | None = None) -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=device_id, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the intent platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_generic_intents_exposed(hass: HomeAssistant) -> None: + """Test the always-on generic intents are exposed.""" + names = await _tool_names(hass) + assert "HassTurnOn" in names + assert "HassTurnOff" in names + + +async def test_timer_intents_require_timer_device(hass: HomeAssistant) -> None: + """Test timer intents are not exposed without a timer-capable device.""" + assert "HassStartTimer" not in await _tool_names(hass) + + +async def test_timer_intents_offered_for_timer_device(hass: HomeAssistant) -> None: + """Test timer intents are exposed for a timer-capable device.""" + + @callback + def handle_timer(*args: object) -> None: + pass + + async_register_timer_handler(hass, "test_device", handle_timer) + + result = await llm_component.async_get_tools( + hass, _llm_context(device_id="test_device"), "assist" + ) + names = {tool.name for tool in result.tools} + assert "HassStartTimer" in names + assert "HassTimerStatus" in names + + +async def test_set_position_requires_exposed_cover(hass: HomeAssistant) -> None: + """Test HassSetPosition is only exposed when a cover/valve is exposed.""" + assert "HassSetPosition" in await _tool_names(hass) + + async_expose_entity(hass, "conversation", COVER_ENTITY_ID, False) + assert "HassSetPosition" not in await _tool_names(hass) + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert intent_llm.async_get_tools(hass, _llm_context(), "other") is None From dc7d3aa72db1b136e013e01b4dace9f2667aae0a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 4 Jul 2026 22:03:04 -0700 Subject: [PATCH 075/707] Bump python-roborock to 5.25.0 (#175645) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 522f24268277..89559924ee30 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.22.0", + "python-roborock==5.25.0", "vacuum-map-parser-roborock==0.1.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 8dd94516aab3..be87716ad981 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2722,7 +2722,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==5.22.0 +python-roborock==5.25.0 # homeassistant.components.smarttub python-smarttub==0.0.47 From ea8d67ab728235c5061f9e4261045dee1c5a7bc2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 07:03:19 +0200 Subject: [PATCH 076/707] Bump dsmr-parser to 1.11.0 (#175639) Co-authored-by: Claude --- homeassistant/components/dsmr/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index c32d947e610c..8255df921a85 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.9.0"] + "requirements": ["dsmr-parser==1.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index be87716ad981..0136f63571f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -856,7 +856,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.9.0 +dsmr-parser==1.11.0 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 From aa2ccf8e60b30e54cf11f9365c25c3a2718a10ee Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:03:53 -0600 Subject: [PATCH 077/707] Bump py_ccm15 to 1.1.2 (#175632) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/ccm15/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json index 4f97fda1c1fb..bc11c2924aa1 100644 --- a/homeassistant/components/ccm15/manifest.json +++ b/homeassistant/components/ccm15/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["py_ccm15==1.1.1"] + "requirements": ["py_ccm15==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0136f63571f3..fdd03163038c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1988,7 +1988,7 @@ pyW215==0.8.0 pyW800rf32==0.4 # homeassistant.components.ccm15 -py_ccm15==1.1.1 +py_ccm15==1.1.2 # homeassistant.components.html5 py_vapid==1.9.4 From 8359e2cbf1309d7b15275764d89a089585b5a3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 5 Jul 2026 08:12:12 +0300 Subject: [PATCH 078/707] Include melcloud_home in Mitsubishi brand (#175635) --- homeassistant/brands/mitsubishi.json | 2 +- homeassistant/generated/integrations.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/brands/mitsubishi.json b/homeassistant/brands/mitsubishi.json index c5c0a2d9c909..830a04566840 100644 --- a/homeassistant/brands/mitsubishi.json +++ b/homeassistant/brands/mitsubishi.json @@ -1,5 +1,5 @@ { "domain": "mitsubishi", "name": "Mitsubishi", - "integrations": ["melcloud", "mitsubishi_comfort"] + "integrations": ["melcloud", "melcloud_home", "mitsubishi_comfort"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 400d0ac4fb54..f45feec8be23 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4139,12 +4139,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "melcloud_home": { - "name": "MELCloud Home", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "melissa": { "name": "Melissa", "integration_type": "hub", @@ -4338,6 +4332,12 @@ "iot_class": "cloud_polling", "name": "MELCloud" }, + "melcloud_home": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "MELCloud Home" + }, "mitsubishi_comfort": { "integration_type": "hub", "config_flow": true, From 4692a4315d16f80b9c034bd7e8757f5960713db2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 07:18:33 +0200 Subject: [PATCH 079/707] Bump denon-rs232 to 4.2.1 (#175636) Co-authored-by: Claude --- homeassistant/components/denon_rs232/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/denon_rs232/manifest.json b/homeassistant/components/denon_rs232/manifest.json index e50677a5f4f6..d8a0c98e96d4 100644 --- a/homeassistant/components/denon_rs232/manifest.json +++ b/homeassistant/components/denon_rs232/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["denon_rs232"], "quality_scale": "bronze", - "requirements": ["denon-rs232==4.1.0"] + "requirements": ["denon-rs232==4.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fdd03163038c..6e5bff8781f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -823,7 +823,7 @@ deluge-client==1.10.2 demetriek==1.3.0 # homeassistant.components.denon_rs232 -denon-rs232==4.1.0 +denon-rs232==4.2.1 # homeassistant.components.denonavr denonavr==1.3.3 From d6fe80d208b2dc96a34447fc749b31f4d249fdd2 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sun, 5 Jul 2026 09:23:39 +0200 Subject: [PATCH 080/707] Bump python-bsblan to 6.1.5 (#175651) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/bsblan/snapshots/test_diagnostics.ambr | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index f0adc5f2eb8b..6a61a9967cff 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==6.1.4"], + "requirements": ["python-bsblan==6.1.5"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/requirements_all.txt b/requirements_all.txt index 6e5bff8781f6..288531353644 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2616,7 +2616,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==6.1.4 +python-bsblan==6.1.5 # homeassistant.components.citybikes python-citybikes==0.3.3 diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index 5ff351d5dbf8..d76c41d35864 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -126,6 +126,7 @@ }), 'states': dict({ '1': dict({ + 'cooling_operating_mode': None, 'current_temperature': dict({ 'data_type': 0, 'data_type_family': '', From 0e1c190eec4c81914b6a3696794f32b8d3d15778 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 12:48:12 +0200 Subject: [PATCH 081/707] Add Modbus Connection integration (#175407) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/modbus_connection/__init__.py | 99 ++++++++++++ .../modbus_connection/config_flow.py | 126 +++++++++++++++ .../components/modbus_connection/const.py | 21 +++ .../modbus_connection/exceptions.py | 25 +++ .../modbus_connection/manifest.json | 12 ++ .../modbus_connection/quality_scale.yaml | 119 +++++++++++++++ .../components/modbus_connection/strings.json | 62 ++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + .../components/modbus_connection/__init__.py | 1 + .../components/modbus_connection/conftest.py | 63 ++++++++ .../modbus_connection/test_config_flow.py | 143 ++++++++++++++++++ .../components/modbus_connection/test_init.py | 128 ++++++++++++++++ 17 files changed, 822 insertions(+) create mode 100644 homeassistant/components/modbus_connection/__init__.py create mode 100644 homeassistant/components/modbus_connection/config_flow.py create mode 100644 homeassistant/components/modbus_connection/const.py create mode 100644 homeassistant/components/modbus_connection/exceptions.py create mode 100644 homeassistant/components/modbus_connection/manifest.json create mode 100644 homeassistant/components/modbus_connection/quality_scale.yaml create mode 100644 homeassistant/components/modbus_connection/strings.json create mode 100644 tests/components/modbus_connection/__init__.py create mode 100644 tests/components/modbus_connection/conftest.py create mode 100644 tests/components/modbus_connection/test_config_flow.py create mode 100644 tests/components/modbus_connection/test_init.py diff --git a/.strict-typing b/.strict-typing index 97a0df52c58d..4b56c5b2c206 100644 --- a/.strict-typing +++ b/.strict-typing @@ -379,6 +379,7 @@ homeassistant.components.min_max.* homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* +homeassistant.components.modbus_connection.* homeassistant.components.modem_callerid.* homeassistant.components.mold_indicator.* homeassistant.components.monzo.* diff --git a/CODEOWNERS b/CODEOWNERS index af40ccba5847..18e1f06d2eb4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1133,6 +1133,8 @@ CLAUDE.md @home-assistant/core /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core +/homeassistant/components/modbus_connection/ @home-assistant/core +/tests/components/modbus_connection/ @home-assistant/core /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/modbus_connection/__init__.py b/homeassistant/components/modbus_connection/__init__.py new file mode 100644 index 000000000000..c09aca8ba8a3 --- /dev/null +++ b/homeassistant/components/modbus_connection/__init__.py @@ -0,0 +1,99 @@ +"""The Modbus Connection integration.""" + +from collections.abc import Mapping +from typing import Any, cast + +from modbus_connection import ModbusConnection, ModbusError, ModbusUnit +from modbus_connection.tmodbus import connect_serial, connect_tcp + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + CONNECTION_SERIAL, + DOMAIN, +) +from .exceptions import ConnectionNotReady + +__all__ = ["ConnectionNotReady", "async_get_unit"] + +type ModbusConnectionConfigEntry = ConfigEntry[ModbusConnection] + + +async def _async_open(data: Mapping[str, Any]) -> ModbusConnection: + """Open the connection described by ``data`` (transport parameters). + + Shared by config-entry setup and the config flow's validation; the caller + owns the returned connection and closes it. + """ + if data[CONF_TYPE] == CONNECTION_SERIAL: + return await connect_serial( + data[CONF_DEVICE], + baudrate=data[CONF_BAUDRATE], + bytesize=data[CONF_BYTESIZE], + parity=data[CONF_PARITY], + stopbits=data[CONF_STOPBITS], + ) + return await connect_tcp(data[CONF_HOST], port=data[CONF_PORT]) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ModbusConnectionConfigEntry +) -> bool: + """Set up a Modbus connection from a config entry.""" + try: + connection = await _async_open(entry.data) + except ModbusError as err: + raise ConfigEntryNotReady(f"Could not open Modbus connection: {err}") from err + + entry.runtime_data = connection + + # The connection is transient and does not self-reconnect: on a drop, reload + # this entry. HA's ConfigEntryNotReady retry is the reconnect backoff. + entry.async_on_unload( + connection.on_connection_lost( + lambda: hass.config_entries.async_schedule_reload(entry.entry_id) + ) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: ModbusConnectionConfigEntry +) -> bool: + """Unload a config entry and close the owned connection.""" + await entry.runtime_data.close() + return True + + +@callback +def async_get_unit( + hass: HomeAssistant, connection_entry_id: str, unit_id: int +) -> ModbusUnit: + """Return a Modbus unit on a shared connection. + + Consumer integrations call this to borrow a ``ModbusUnit`` bound to their + unit ID; the ``ModbusConnection`` itself never leaves this integration. + + Raises ``ValueError`` if ``connection_entry_id`` is unknown or does not point + at a ``modbus_connection`` entry (a programming error in the consumer). Raises + ``ConnectionNotReady`` if that entry exists but is not loaded; it is a + ``ConfigEntryNotReady``, so a consumer can let it propagate from its own + ``async_setup_entry`` to get Home Assistant's setup retry. + """ + entry = cast( + "ModbusConnectionConfigEntry | None", + hass.config_entries.async_get_entry(connection_entry_id), + ) + if entry is None or entry.domain != DOMAIN: + raise ValueError(f"{connection_entry_id} is not a modbus_connection entry") + if entry.state is not ConfigEntryState.LOADED: + raise ConnectionNotReady(connection_entry_id) + return entry.runtime_data.for_unit(unit_id) diff --git a/homeassistant/components/modbus_connection/config_flow.py b/homeassistant/components/modbus_connection/config_flow.py new file mode 100644 index 000000000000..dd0e3adc8ae5 --- /dev/null +++ b/homeassistant/components/modbus_connection/config_flow.py @@ -0,0 +1,126 @@ +"""Config flow for the Modbus Connection integration.""" + +from typing import Any, override + +from modbus_connection import ModbusError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + SerialPortSelector, +) + +from . import _async_open +from .const import ( + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + CONNECTION_SERIAL, + CONNECTION_TCP, + DEFAULT_BAUDRATE, + DEFAULT_BYTESIZE, + DEFAULT_PARITY, + DEFAULT_PORT, + DEFAULT_STOPBITS, + DOMAIN, +) + +STEP_MODBUS_TCP = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=65535) + ), + } +) + +# SerialPortSelector lists local serial ports and network serial proxies. +STEP_SERIAL = vol.Schema( + { + vol.Required(CONF_DEVICE): SerialPortSelector(), + vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Required(CONF_PARITY, default=DEFAULT_PARITY): SelectSelector( + SelectSelectorConfig( + options=["n", "e", "o"], + translation_key="parity", + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONF_STOPBITS, default=DEFAULT_STOPBITS): vol.In([1, 2]), + vol.Required(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In([7, 8]), + } +) + + +class ModbusConnectionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Modbus Connection.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user choose the transport.""" + return self.async_show_menu( + step_id="user", + menu_options=["modbus_tcp", "serial"], + ) + + async def async_step_modbus_tcp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure a Modbus TCP / RTU-over-TCP connection.""" + errors: dict[str, str] = {} + if user_input is not None: + data = {CONF_TYPE: CONNECTION_TCP, **user_input} + # Dedupe before opening: most Modbus devices reject a second client. + self._async_abort_entries_match(data) + if not (errors := await self._async_validate(data)): + return self.async_create_entry( + title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data + ) + return self.async_show_form( + step_id="modbus_tcp", data_schema=STEP_MODBUS_TCP, errors=errors + ) + + async def async_step_serial( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure a Modbus serial (RTU) connection, incl. network serial proxies.""" + errors: dict[str, str] = {} + if user_input is not None: + data = { + CONF_TYPE: CONNECTION_SERIAL, + **user_input, + # Store the uppercase parity code the connection expects. + CONF_PARITY: user_input[CONF_PARITY].upper(), + } + # A serial link is identified by its device path alone, regardless of + # baud rate and other line settings. + self._async_abort_entries_match( + {CONF_TYPE: CONNECTION_SERIAL, CONF_DEVICE: data[CONF_DEVICE]} + ) + if not (errors := await self._async_validate(data)): + return self.async_create_entry(title=data[CONF_DEVICE], data=data) + return self.async_show_form( + step_id="serial", data_schema=STEP_SERIAL, errors=errors + ) + + async def _async_validate(self, data: dict[str, Any]) -> dict[str, str]: + """Validate by actually opening the connection; return form errors.""" + try: + connection = await _async_open(data) + except ModbusError: + if data[CONF_TYPE] == CONNECTION_SERIAL: + return {"base": "cannot_open_serial_port"} + return {"base": "cannot_connect"} + await connection.close() + return {} diff --git a/homeassistant/components/modbus_connection/const.py b/homeassistant/components/modbus_connection/const.py new file mode 100644 index 000000000000..369ecc4c5a09 --- /dev/null +++ b/homeassistant/components/modbus_connection/const.py @@ -0,0 +1,21 @@ +"""Constants for the Modbus Connection integration.""" + +from typing import Final + +DOMAIN: Final = "modbus_connection" + +# Transport selection (stored under homeassistant.const.CONF_TYPE). +CONNECTION_TCP: Final = "tcp" +CONNECTION_SERIAL: Final = "serial" + +# Serial-only options. +CONF_BAUDRATE: Final = "baudrate" +CONF_BYTESIZE: Final = "bytesize" +CONF_PARITY: Final = "parity" +CONF_STOPBITS: Final = "stopbits" + +DEFAULT_PORT: Final = 502 +DEFAULT_BAUDRATE: Final = 9600 +DEFAULT_BYTESIZE: Final = 8 +DEFAULT_PARITY: Final = "n" +DEFAULT_STOPBITS: Final = 1 diff --git a/homeassistant/components/modbus_connection/exceptions.py b/homeassistant/components/modbus_connection/exceptions.py new file mode 100644 index 000000000000..5c1ee68134b9 --- /dev/null +++ b/homeassistant/components/modbus_connection/exceptions.py @@ -0,0 +1,25 @@ +"""Exceptions for the Modbus Connection integration.""" + +from modbus_connection import ModbusError + +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + + +class ConnectionNotReady(ConfigEntryNotReady, ModbusError): + """The shared Modbus connection is missing or not loaded. + + Raised by ``async_get_unit``. It is a ``ConfigEntryNotReady`` so a consumer + integration can let it propagate from its own ``async_setup_entry`` to get + Home Assistant's setup-retry behaviour, and a ``ModbusError`` so it is also + catchable with the library's error type. + """ + + def __init__(self, connection_entry_id: str) -> None: + """Initialize the error.""" + super().__init__( + translation_domain=DOMAIN, + translation_key="connection_not_ready", + ) + self.connection_entry_id = connection_entry_id diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json new file mode 100644 index 000000000000..4bac19ead28c --- /dev/null +++ b/homeassistant/components/modbus_connection/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "modbus_connection", + "name": "Modbus Connection", + "codeowners": ["@home-assistant/core"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/modbus_connection", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["modbus_connection", "tmodbus"], + "quality_scale": "bronze", + "requirements": ["modbus-connection[tmodbus]==3.3.0"] +} diff --git a/homeassistant/components/modbus_connection/quality_scale.yaml b/homeassistant/components/modbus_connection/quality_scale.yaml new file mode 100644 index 000000000000..6eb47cc6c8b6 --- /dev/null +++ b/homeassistant/components/modbus_connection/quality_scale.yaml @@ -0,0 +1,119 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any service actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. It owns a connection and hands out units; + consumer integrations poll through their own coordinators. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register any service actions. + docs-conditions: + status: exempt + comment: This integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: This integration provides no entities. + entity-unique-id: + status: exempt + comment: This integration provides no entities. + has-entity-name: + status: exempt + comment: This integration provides no entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: This integration does not register any service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration provides no entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration provides no entities; availability is surfaced to + consumers via on_connection_lost and failing reads. + parallel-updates: + status: exempt + comment: This integration provides no entity platforms. + reauthentication-flow: + status: exempt + comment: A Modbus link has no authentication. + test-coverage: done + # Gold + devices: + status: exempt + comment: This integration provides connections, not devices or entities. + diagnostics: todo + discovery: + status: exempt + comment: Modbus links are not discoverable. + discovery-update-info: + status: exempt + comment: Modbus links are not discoverable. + docs-data-update: + status: exempt + comment: This integration provides no entities to update. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: This integration is a connection provider, not a device integration. + docs-supported-functions: + status: exempt + comment: This integration provides no entities. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration provides no devices. + entity-category: + status: exempt + comment: This integration provides no entities. + entity-device-class: + status: exempt + comment: This integration provides no entities. + entity-disabled-by-default: + status: exempt + comment: This integration provides no entities. + entity-translations: + status: exempt + comment: This integration provides no entities. + exception-translations: todo + icon-translations: + status: exempt + comment: This integration provides no entities. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairable issues are raised. + stale-devices: + status: exempt + comment: This integration provides no devices. + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration talks Modbus, not HTTP. + strict-typing: done diff --git a/homeassistant/components/modbus_connection/strings.json b/homeassistant/components/modbus_connection/strings.json new file mode 100644 index 000000000000..a71d59af83bf --- /dev/null +++ b/homeassistant/components/modbus_connection/strings.json @@ -0,0 +1,62 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_open_serial_port": "Failed to open the serial port" + }, + "step": { + "modbus_tcp": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Modbus gateway or device.", + "port": "The TCP port the Modbus gateway listens on (default 502)." + }, + "title": "Modbus TCP" + }, + "serial": { + "data": { + "baudrate": "Baud rate", + "bytesize": "Byte size", + "device": "[%key:common::config_flow::data::device%]", + "parity": "Parity", + "stopbits": "Stop bits" + }, + "data_description": { + "baudrate": "The serial baud rate the device communicates at.", + "bytesize": "The number of data bits.", + "device": "The serial port the Modbus device is connected to, e.g. /dev/ttyUSB0.", + "parity": "The serial parity.", + "stopbits": "The number of stop bits." + }, + "title": "Serial connection" + }, + "user": { + "description": "How is the Modbus network connected?", + "menu_options": { + "modbus_tcp": "Modbus TCP", + "serial": "Serial (including serial proxies and networked connections)" + } + } + } + }, + "exceptions": { + "connection_not_ready": { + "message": "Modbus connection not ready" + } + }, + "selector": { + "parity": { + "options": { + "e": "Even", + "n": "None", + "o": "Odd" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 23026bbda1bd..b3be40e3696e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -469,6 +469,7 @@ FLOWS = { "mjpeg", "moat", "mobile_app", + "modbus_connection", "modem_callerid", "modern_forms", "moehlenhoff_alpha2", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f45feec8be23..361b332c997b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4374,6 +4374,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "modbus_connection": { + "name": "Modbus Connection", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "modem_callerid": { "name": "Phone Modem", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 9791660c9a21..77911105e280 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3547,6 +3547,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.modbus_connection.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.modem_callerid.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 288531353644..86012455e302 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1585,6 +1585,9 @@ mitsubishi-comfort==0.3.2 # homeassistant.components.moat moat-ble==0.1.1 +# homeassistant.components.modbus_connection +modbus-connection[tmodbus]==3.3.0 + # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 diff --git a/tests/components/modbus_connection/__init__.py b/tests/components/modbus_connection/__init__.py new file mode 100644 index 000000000000..ecbad3432af6 --- /dev/null +++ b/tests/components/modbus_connection/__init__.py @@ -0,0 +1 @@ +"""Tests for the Modbus Connection integration.""" diff --git a/tests/components/modbus_connection/conftest.py b/tests/components/modbus_connection/conftest.py new file mode 100644 index 000000000000..379fcd664435 --- /dev/null +++ b/tests/components/modbus_connection/conftest.py @@ -0,0 +1,63 @@ +"""Common fixtures for the Modbus Connection tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from modbus_connection.mock import MockModbusConnection +import pytest + +from homeassistant.components.modbus_connection.const import CONNECTION_TCP, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Prevent the created entry from actually setting up during flow tests.""" + with patch( + "homeassistant.components.modbus_connection.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_connect( + mock_modbus_connection: MockModbusConnection, +) -> Generator[AsyncMock]: + """Patch the backend connect functions to return the mock connection.""" + connect = AsyncMock(return_value=mock_modbus_connection) + with ( + patch("homeassistant.components.modbus_connection.connect_tcp", connect), + patch("homeassistant.components.modbus_connection.connect_serial", connect), + ): + yield connect + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a TCP connection config entry, already added to hass.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="1.2.3.4:502", + data={CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, +) -> MockConfigEntry: + """Set up the connection entry (loaded). + + Relies on ``mock_config_entry`` already being in hass. + """ + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/modbus_connection/test_config_flow.py b/tests/components/modbus_connection/test_config_flow.py new file mode 100644 index 000000000000..cb5d4d199caf --- /dev/null +++ b/tests/components/modbus_connection/test_config_flow.py @@ -0,0 +1,143 @@ +"""Tests for the Modbus Connection config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +from modbus_connection import ModbusConnectionError +from modbus_connection.mock import MockModbusConnection +import pytest + +from homeassistant.components.modbus_connection.const import ( + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + CONNECTION_SERIAL, + CONNECTION_TCP, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +SERIAL_INPUT = { + CONF_DEVICE: "/dev/ttyUSB0", + CONF_BAUDRATE: 9600, + CONF_PARITY: "n", + CONF_STOPBITS: 1, + CONF_BYTESIZE: 8, +} + + +async def _start_menu(hass: HomeAssistant, step: str) -> str: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert set(result["menu_options"]) == {"modbus_tcp", "serial"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": step} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == step + return result["flow_id"] + + +@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") +async def test_modbus_tcp_flow(hass: HomeAssistant) -> None: + """The Modbus TCP step opens the connection and creates an entry.""" + flow_id = await _start_menu(hass, "modbus_tcp") + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_TYPE: CONNECTION_TCP, + CONF_HOST: "1.2.3.4", + CONF_PORT: 502, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_modbus_tcp_cannot_connect_then_recovers( + hass: HomeAssistant, + mock_connect: AsyncMock, + mock_modbus_connection: MockModbusConnection, +) -> None: + """A failed probe shows an error; a later success creates the entry.""" + flow_id = await _start_menu(hass, "modbus_tcp") + mock_connect.side_effect = ModbusConnectionError("nope") + result = await hass.config_entries.flow.async_configure( + flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_connect.side_effect = None + mock_connect.return_value = mock_modbus_connection + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_PORT: 502} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") +async def test_serial_flow(hass: HomeAssistant) -> None: + """The serial step opens the connection and creates a serial entry.""" + flow_id = await _start_menu(hass, "serial") + result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) + assert result["type"] is FlowResultType.CREATE_ENTRY + # Parity is stored uppercase (the code the connection expects). + assert result["data"] == { + CONF_TYPE: CONNECTION_SERIAL, + **SERIAL_INPUT, + CONF_PARITY: "N", + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_serial_cannot_open(hass: HomeAssistant, mock_connect: AsyncMock) -> None: + """A failed serial open shows the serial-specific error.""" + flow_id = await _start_menu(hass, "serial") + mock_connect.side_effect = ModbusConnectionError("nope") + result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_open_serial_port"} + + +@pytest.mark.parametrize( + ("step", "data", "user_input"), + [ + pytest.param( + "modbus_tcp", + {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, + {CONF_HOST: "1.2.3.4", CONF_PORT: 502}, + id="modbus_tcp", + ), + pytest.param( + "serial", + {CONF_TYPE: CONNECTION_SERIAL, **SERIAL_INPUT}, + SERIAL_INPUT, + id="serial", + ), + ], +) +async def test_duplicate_aborts( + hass: HomeAssistant, + step: str, + data: dict[str, Any], + user_input: dict[str, Any], +) -> None: + """Re-adding an already-configured link aborts before opening it. + + The dedupe runs before opening the connection, so no connect is needed. + """ + MockConfigEntry(domain=DOMAIN, data=data).add_to_hass(hass) + + flow_id = await _start_menu(hass, step) + result = await hass.config_entries.flow.async_configure(flow_id, user_input) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/modbus_connection/test_init.py b/tests/components/modbus_connection/test_init.py new file mode 100644 index 000000000000..9b49de25ef39 --- /dev/null +++ b/tests/components/modbus_connection/test_init.py @@ -0,0 +1,128 @@ +"""Tests for Modbus Connection setup, teardown and the async_get_unit accessor.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from modbus_connection import ModbusConnectionError, ModbusError +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant.components.modbus_connection import ( + ConnectionNotReady, + async_get_unit, +) +from homeassistant.components.modbus_connection.const import ( + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + CONNECTION_SERIAL, + CONNECTION_TCP, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """A connection entry loads, exposes runtime data, and closes on unload.""" + assert init_integration.state is ConfigEntryState.LOADED + assert init_integration.runtime_data is mock_modbus_connection + assert mock_modbus_connection.connected is True + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + assert mock_modbus_connection.connected is False + + +@pytest.mark.parametrize( + ("data", "error"), + [ + pytest.param( + {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, + ModbusConnectionError("boom"), + id="tcp", + ), + pytest.param( + { + CONF_TYPE: CONNECTION_SERIAL, + CONF_DEVICE: "/dev/ttyUSB0", + CONF_BAUDRATE: 9600, + CONF_PARITY: "N", + CONF_STOPBITS: 1, + CONF_BYTESIZE: 8, + }, + ModbusError("port busy"), + id="serial", + ), + ], +) +async def test_setup_retry_when_connect_fails( + hass: HomeAssistant, + mock_connect: AsyncMock, + data: dict[str, Any], + error: ModbusError, +) -> None: + """A failed open raises ConfigEntryNotReady (setup retry). + + The serial case uses a generic ``ModbusError`` (not a ``ModbusConnectionError``) + to confirm setup retries on any library error, matching the config flow. + """ + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + mock_connect.side_effect = error + + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_connection_lost_schedules_reload( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Losing the connection schedules a reload of the entry.""" + with patch.object(hass.config_entries, "async_schedule_reload") as schedule_reload: + mock_modbus_connection.simulate_connection_lost() + await hass.async_block_till_done() + + schedule_reload.assert_called_once_with(init_integration.entry_id) + + +async def test_get_unit_returns_connection_unit( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """async_get_unit hands back the connection's own unit handle.""" + assert async_get_unit(hass, init_integration.entry_id, 1) is mock_modbus_unit + + +async def test_get_unit_not_ready_when_unloaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A modbus_connection entry that is not loaded raises ConnectionNotReady.""" + # mock_config_entry is added to hass but never set up -> not LOADED. + with pytest.raises(ConnectionNotReady): + async_get_unit(hass, mock_config_entry.entry_id, 1) + + +async def test_get_unit_rejects_invalid_entry(hass: HomeAssistant) -> None: + """An unknown entry_id or a foreign-domain entry raises ValueError.""" + with pytest.raises(ValueError): + async_get_unit(hass, "does-not-exist", 1) + + other = MockConfigEntry(domain="sun", state=ConfigEntryState.LOADED) + other.add_to_hass(hass) + with pytest.raises(ValueError): + async_get_unit(hass, other.entry_id, 1) From a71e9cc088a4374d7897bcb01534b5e371ba47bc Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Sun, 5 Jul 2026 15:49:45 +0200 Subject: [PATCH 082/707] Add StiebelEltronEntity base class for stiebel_eltron (#175114) Co-authored-by: Claude Sonnet 4.6 --- .../components/stiebel_eltron/climate.py | 21 +++++++-------- .../components/stiebel_eltron/coordinator.py | 7 ----- .../components/stiebel_eltron/entity.py | 19 +++++++++++++ .../components/stiebel_eltron/test_climate.py | 27 +++++++++++++++++++ tests/components/stiebel_eltron/test_init.py | 16 +++++++++++ 5 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/stiebel_eltron/entity.py diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 1f1b3ab4c8e1..047fd029c738 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -17,13 +17,15 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import StiebelEltronConfigEntry from .coordinator import StiebelEltronDataCoordinator +from .entity import StiebelEltronEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CLIMATE_HK_1 = "climate_hk_1" # Mapping STIEBEL ELTRON states to homeassistant states/preset. @@ -79,13 +81,12 @@ async def async_setup_entry( ) -> None: """Set up STIEBEL ELTRON climate platform.""" - async_add_entities([StiebelEltron(entry.entry_id, entry.runtime_data)]) + async_add_entities([StiebelEltron(entry.runtime_data)]) -class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEntity): +class StiebelEltron(StiebelEltronEntity, ClimateEntity): """Representation of a STIEBEL ELTRON heat pump.""" - _attr_has_entity_name = True _attr_name = None _attr_hvac_modes = list(HA_TO_LWZ_HVAC) _attr_preset_modes = list(HA_TO_LWZ_PRESET) @@ -100,14 +101,12 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti _attr_min_temp = 10.0 _attr_max_temp = 30.0 - def __init__( - self, unique_id: str, coordinator: StiebelEltronDataCoordinator - ) -> None: + def __init__(self, coordinator: StiebelEltronDataCoordinator) -> None: """Initialize the unit.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{unique_id}-{CLIMATE_HK_1}" - # Initialize runtime attributes to avoid attribute errors + assert coordinator.config_entry is not None + super().__init__( + coordinator, f"{coordinator.config_entry.entry_id}-{CLIMATE_HK_1}" + ) self._set_attr() @override diff --git a/homeassistant/components/stiebel_eltron/coordinator.py b/homeassistant/components/stiebel_eltron/coordinator.py index 4b2fda2ce817..2b278fb0169c 100644 --- a/homeassistant/components/stiebel_eltron/coordinator.py +++ b/homeassistant/components/stiebel_eltron/coordinator.py @@ -58,16 +58,9 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]): _LOGGER.debug("Closing connection to %s", self.host) await self.api_client.close() - async def connect(self) -> None: - """Connect client.""" - _LOGGER.debug("Connecting to %s", self.host) - await self.api_client.connect() - @property def is_connected(self) -> bool: """Check modbus client connection status.""" - if self.api_client is None: - return False return self.api_client.is_connected @property diff --git a/homeassistant/components/stiebel_eltron/entity.py b/homeassistant/components/stiebel_eltron/entity.py new file mode 100644 index 000000000000..63fc2a96062d --- /dev/null +++ b/homeassistant/components/stiebel_eltron/entity.py @@ -0,0 +1,19 @@ +"""Base entity for the STIEBEL ELTRON integration.""" + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import StiebelEltronDataCoordinator + + +class StiebelEltronEntity(CoordinatorEntity[StiebelEltronDataCoordinator]): + """Base class for STIEBEL ELTRON entities.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: StiebelEltronDataCoordinator, unique_id: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + self._attr_unique_id = unique_id diff --git a/tests/components/stiebel_eltron/test_climate.py b/tests/components/stiebel_eltron/test_climate.py index 2fcf832deee1..d24f4650a902 100644 --- a/tests/components/stiebel_eltron/test_climate.py +++ b/tests/components/stiebel_eltron/test_climate.py @@ -169,6 +169,33 @@ async def test_climate_entity_set_temperature( mock_lwz_api.set_target_temp.assert_awaited_with(23.5) +async def test_climate_entity_set_hvac_mode_handles_api_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, +) -> None: + """Test setting HVAC mode handles API exception.""" + mock_lwz_api.get_operation.return_value = None + await _setup_integration(hass, mock_config_entry) + + mock_lwz_api.set_operation.side_effect = ModbusException("write failed") + with pytest.raises(HomeAssistantError): + await async_set_hvac_mode(hass, HVACMode.AUTO, CLIMATE_ENTITY_ID) + + +async def test_climate_entity_set_preset_mode_handles_api_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, +) -> None: + """Test setting preset mode handles API exception.""" + await _setup_integration(hass, mock_config_entry) + + mock_lwz_api.set_operation.side_effect = ModbusException("write failed") + with pytest.raises(HomeAssistantError): + await async_set_preset_mode(hass, PRESET_COMFORT, CLIMATE_ENTITY_ID) + + async def test_climate_entity_set_temperature_handles_api_exception( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py index 4fe188ce0b10..44fa7259309a 100644 --- a/tests/components/stiebel_eltron/test_init.py +++ b/tests/components/stiebel_eltron/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from pymodbus.exceptions import ModbusException from pystiebeleltron import StiebelEltronModbusError from homeassistant.components.stiebel_eltron.const import DOMAIN @@ -73,3 +74,18 @@ async def test_async_setup_entry_modbus_error( assert result is False assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_async_setup_entry_coordinator_update_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, +) -> None: + """Test setup retries when coordinator data update raises ModbusException.""" + mock_lwz_api.async_update.side_effect = ModbusException("update failed") + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From dad5bcd81fad837b2fb873dfbc9b015899ca4cd9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:37:43 +0200 Subject: [PATCH 083/707] Use covariant type for SearchMedia result argument (#175678) --- homeassistant/components/media_player/browse_media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index 2b26f999907c..eaa289640e1f 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -174,7 +174,7 @@ class SearchMedia: """Represent search results.""" version: int = field(default=1) - result: list[BrowseMedia] + result: Sequence[BrowseMedia] def as_dict(self, *, parent: bool = True) -> dict[str, Any]: """Convert SearchMedia class to browse media dictionary.""" From d460502c05f5c263bbeec71f5a0b8bac04a854ee Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:10:05 +0200 Subject: [PATCH 084/707] Bump aioimmich to 0.16.1 (#175676) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 4c4c4484f9d2..1f438e8e3d12 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.16.0"] + "requirements": ["aioimmich==0.16.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86012455e302..f52760f9a768 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -303,7 +303,7 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.16.0 +aioimmich==0.16.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 005af604784dcbbfd38c470149f2d03de4c3dee3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Jul 2026 17:36:00 +0200 Subject: [PATCH 085/707] Lazy media source platforms (#174375) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/ai_task/__init__.py | 5 +++ .../components/ai_task/media_source.py | 9 ++-- .../components/media_source/__init__.py | 25 +++++------ .../components/media_source/const.py | 7 ++- .../components/media_source/helper.py | 38 ++++++++++------ .../components/media_source/local_source.py | 6 +-- .../components/media_source/models.py | 45 +++++++++++++++---- homeassistant/helpers/integration_platform.py | 9 +++- tests/components/ai_task/test_media_source.py | 3 ++ tests/components/media_source/test_helper.py | 27 ++++++----- tests/helpers/test_integration_platform.py | 22 +++++++++ 11 files changed, 139 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index e88a2960379f..3840fc515b0a 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -5,6 +5,7 @@ from typing import Any import voluptuous as vol +from homeassistant.components.media_source import local_source from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR from homeassistant.core import ( @@ -34,6 +35,7 @@ from .const import ( ) from .entity import AITaskEntity from .http import async_setup as async_setup_http +from .media_source import async_get_media_source from .task import ( GenDataTask, GenDataTaskResult, @@ -88,6 +90,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) await hass.data[DATA_PREFERENCES].async_load() async_setup_http(hass) + if hass.config.media_dirs: + source = await async_get_media_source(hass) + hass.http.register_view(local_source.LocalMediaView(hass, source)) hass.services.async_register( DOMAIN, SERVICE_GENERATE_DATA, diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py index 9f0e493b0ada..378809eee554 100644 --- a/homeassistant/components/ai_task/media_source.py +++ b/homeassistant/components/ai_task/media_source.py @@ -2,14 +2,16 @@ from pathlib import Path -from homeassistant.components.media_source import MediaSource, local_source +from homeassistant.components.media_source import local_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.singleton import singleton from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR -async def async_get_media_source(hass: HomeAssistant) -> MediaSource: +@singleton(DATA_MEDIA_SOURCE, async_=True) +async def async_get_media_source(hass: HomeAssistant) -> local_source.LocalSource: """Set up local media source.""" media_dirs = list(hass.config.media_dirs.values()) @@ -20,11 +22,10 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR - hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource( + return local_source.LocalSource( hass, DOMAIN, "AI generated images", {IMAGE_DIR: str(media_dir)}, f"/{DOMAIN}", ) - return source diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 030f68bf4142..bc68335fcad3 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -5,17 +5,16 @@ from typing import Protocol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.integration_platform import ( - async_process_integration_platforms, -) +from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms from homeassistant.helpers.typing import ConfigType from . import http, local_source from .const import ( + DATA_LOCAL_SOURCE, + DATA_MEDIA_SOURCE_PLATFORMS, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, - MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX, ) @@ -73,17 +72,18 @@ def generate_media_source_id(domain: str, identifier: str) -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media_source component.""" - hass.data[MEDIA_SOURCE_DATA] = {} + hass.data[DATA_MEDIA_SOURCE_PLATFORMS] = LazyIntegrationPlatforms[MediaSource]( + hass, DOMAIN, _process_media_source_platform + ) http.async_setup(hass) # Local sources support - await _process_media_source_platform(hass, DOMAIN, local_source) + source = await local_source.async_get_media_source(hass) + hass.data[DATA_LOCAL_SOURCE] = source + hass.http.register_view(local_source.LocalMediaView(hass, source)) hass.http.register_view(local_source.UploadMediaView) websocket_api.async_register_command(hass, local_source.websocket_remove_media) - await async_process_integration_platforms( - hass, DOMAIN, _process_media_source_platform - ) return True @@ -91,9 +91,6 @@ async def _process_media_source_platform( hass: HomeAssistant, domain: str, platform: MediaSourceProtocol, -) -> None: +) -> MediaSource: """Process a media source platform.""" - source = await platform.async_get_media_source(hass) - hass.data[MEDIA_SOURCE_DATA][domain] = source - if isinstance(source, local_source.LocalSource): - hass.http.register_view(local_source.LocalMediaView(hass, source)) + return await platform.async_get_media_source(hass) diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index 1e9a7cc1eaa2..e7cc4499b006 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -7,10 +7,15 @@ from homeassistant.components.media_player import MediaClass from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: + from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms + from .models import MediaSource DOMAIN = "media_source" -MEDIA_SOURCE_DATA: HassKey[dict[str, MediaSource]] = HassKey(DOMAIN) +DATA_LOCAL_SOURCE: HassKey[MediaSource] = HassKey("media_source_local_source") +DATA_MEDIA_SOURCE_PLATFORMS: HassKey[LazyIntegrationPlatforms[MediaSource]] = HassKey( + "media_source_platforms" +) MEDIA_MIME_TYPES = ("audio", "video", "image") MEDIA_CLASS_MAP = { "audio": MediaClass.MUSIC, diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py index 099774deaa8d..92eff623bc3d 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -8,17 +8,23 @@ from homeassistant.components.media_player import ( SearchMedia, SearchMediaQuery, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.frame import report_usage from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .const import DOMAIN, MEDIA_SOURCE_DATA +from .const import DOMAIN from .error import UnknownMediaSource, Unresolvable -from .models import BrowseMediaSource, MediaSourceItem, PlayMedia, RootBrowseMediaSource +from .models import ( + BrowseMediaSource, + MediaSourceItem, + PlayMedia, + RootBrowseMediaSource, + _async_get_media_source, + _async_get_media_sources, +) -@callback -def _get_media_item( +async def _get_media_item( hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None ) -> MediaSourceItem: """Return media item.""" @@ -26,10 +32,14 @@ def _get_media_item( item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player) else: # We default to our own domain if its only one registered - domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN + sources = await _async_get_media_sources(hass) + domain = None if len(sources) > 1 else DOMAIN return MediaSourceItem(hass, domain, "", target_media_player) - if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]: + if ( + item.domain is not None + and await _async_get_media_source(hass, item.domain) is None + ): raise UnknownMediaSource( translation_domain=DOMAIN, translation_key="unknown_media_source", @@ -46,11 +56,12 @@ async def async_browse_media( content_filter: Callable[[BrowseMedia], bool] | None = None, ) -> BrowseMediaSource | RootBrowseMediaSource: """Return media player browse media results.""" - if DOMAIN not in hass.data: + if DOMAIN not in hass.config.top_level_components: raise BrowseError("Media Source not loaded") try: - item = await _get_media_item(hass, media_content_id, None).async_browse() + media_item = await _get_media_item(hass, media_content_id, None) + item = await media_item.async_browse() except ValueError as err: raise BrowseError( translation_domain=DOMAIN, @@ -78,11 +89,12 @@ async def async_search_media( query: SearchMediaQuery, ) -> SearchMedia: """Return media searched in the media source.""" - if DOMAIN not in hass.data: + if DOMAIN not in hass.config.top_level_components: raise BrowseError("Media Source not loaded") try: - return await _get_media_item(hass, media_content_id, None).async_search(query) + media_item = await _get_media_item(hass, media_content_id, None) + return await media_item.async_search(query) except NotImplementedError as err: raise BrowseError( translation_domain=DOMAIN, @@ -106,7 +118,7 @@ async def async_resolve_media( target_media_player: str | None | UndefinedType = UNDEFINED, ) -> PlayMedia: """Get info to play media.""" - if DOMAIN not in hass.data: + if DOMAIN not in hass.config.top_level_components: raise Unresolvable("Media Source not loaded") if target_media_player is UNDEFINED: @@ -117,7 +129,7 @@ async def async_resolve_media( target_media_player = None try: - item = _get_media_item(hass, media_content_id, target_media_player) + item = await _get_media_item(hass, media_content_id, target_media_player) except ValueError as err: raise Unresolvable( translation_domain=DOMAIN, diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 5d9ca3fb581e..49fb54b225ae 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path -from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA +from .const import DATA_LOCAL_SOURCE, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia @@ -446,7 +446,7 @@ class UploadMediaView(http.HomeAssistantView): if target_folder.domain != DOMAIN: raise web.HTTPBadRequest - source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][target_folder.domain]) + source = cast(LocalSource, hass.data[DATA_LOCAL_SOURCE]) try: uploaded_media_source_id = await source.async_upload_media( target_folder, data["file"] @@ -491,7 +491,7 @@ async def websocket_remove_media( ) return - source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][item.domain]) + source = cast(LocalSource, hass.data[DATA_LOCAL_SOURCE]) try: await source.async_delete_media(item) diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index fdf34c9f594b..2a341f3d0c3b 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -10,15 +10,37 @@ from homeassistant.components.media_player import ( SearchMedia, SearchMediaQuery, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.translation import async_get_cached_translations -from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX +from .const import ( + DATA_LOCAL_SOURCE, + DATA_MEDIA_SOURCE_PLATFORMS, + DOMAIN, + URI_SCHEME, + URI_SCHEME_REGEX, +) if TYPE_CHECKING: from pathlib import Path +async def _async_get_media_sources(hass: HomeAssistant) -> dict[str, MediaSource]: + """Return all media sources, loading integration platforms on demand.""" + sources: dict[str, MediaSource] = {DOMAIN: hass.data[DATA_LOCAL_SOURCE]} + sources.update(await hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platforms()) + return sources + + +async def _async_get_media_source( + hass: HomeAssistant, domain: str +) -> MediaSource | None: + """Return the media source for a domain, loading it on demand.""" + if domain == DOMAIN: + return hass.data[DATA_LOCAL_SOURCE] + return await hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platform(domain) + + @dataclass(slots=True) class PlayMedia: """Represents a playable media.""" @@ -87,6 +109,7 @@ class MediaSourceItem: can_expand=True, children_media_class=MediaClass.APP, ) + sources = await _async_get_media_sources(self.hass) base.children = sorted( ( BrowseMediaSource( @@ -99,13 +122,14 @@ class MediaSourceItem: can_play=False, can_expand=True, ) - for source in self.hass.data[MEDIA_SOURCE_DATA].values() + for source in sources.values() ), key=lambda item: item.title, ) return base - return await self.async_media_source().async_browse_media(self) + source = await self._async_media_source() + return await source.async_browse_media(self) async def async_search(self, query: SearchMediaQuery) -> SearchMedia: """Search this item.""" @@ -114,18 +138,21 @@ class MediaSourceItem: if self.domain is None: raise NotImplementedError - return await self.async_media_source().async_search_media(self, query) + return await (await self._async_media_source()).async_search_media(self, query) async def async_resolve(self) -> PlayMedia: """Resolve to playable item.""" - return await self.async_media_source().async_resolve_media(self) + source = await self._async_media_source() + return await source.async_resolve_media(self) - @callback - def async_media_source(self) -> MediaSource: + async def _async_media_source(self) -> MediaSource: """Return media source that owns this item.""" if TYPE_CHECKING: assert self.domain is not None - return self.hass.data[MEDIA_SOURCE_DATA][self.domain] + # Existence is validated by _get_media_item before browse/resolve. + source = await _async_get_media_source(self.hass, self.domain) + assert source is not None + return source @classmethod def from_uri( diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 7fc212c9dba3..236eb7556138 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -266,7 +266,7 @@ async def _async_process_integration_platforms( # Any = platform. -type ProcessPlatform[_R] = Callable[[HomeAssistant, str, Any], _R] +type ProcessPlatform[_R] = Callable[[HomeAssistant, str, Any], _R | Awaitable[_R]] class LazyIntegrationPlatforms[_R]: @@ -277,6 +277,8 @@ class LazyIntegrationPlatforms[_R]: this only imports and processes the platform for an integration the first time it is requested, and only for integrations that are loaded. + The process callback may be a coroutine function; its result is awaited. + The platform is intentionally not registered for preloading, since for a rarely used platform that would import it for every integration during loading, defeating the point of loading it lazily. @@ -358,7 +360,10 @@ class LazyIntegrationPlatforms[_R]: result: _R | None = None if platform is not None: try: - result = self._process_platform(self._hass, domain, platform) + processed = self._process_platform(self._hass, domain, platform) + if isinstance(processed, Awaitable): + processed = await processed + result = processed except Exception: _LOGGER.exception( "Error processing %s platform for %s", diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py index f41992f74cde..883f3953562b 100644 --- a/tests/components/ai_task/test_media_source.py +++ b/tests/components/ai_task/test_media_source.py @@ -26,6 +26,9 @@ async def test_local_media_source(hass: HomeAssistant, init_components: None) -> ) assert source.url_prefix == "/ai_task" + +async def test_media_source_no_media_dirs(hass: HomeAssistant) -> None: + """Test an error is raised when no media directories are configured.""" hass.config.media_dirs = {} with pytest.raises( diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py index 03be63acc4bc..0f5a236ac6d4 100644 --- a/tests/components/media_source/test_helper.py +++ b/tests/components/media_source/test_helper.py @@ -1,6 +1,6 @@ """Test media source helpers.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -11,7 +11,7 @@ from homeassistant.components.media_player import ( SearchMediaQuery, ) from homeassistant.components.media_source import const, models -from homeassistant.components.media_source.const import MEDIA_SOURCE_DATA +from homeassistant.components.media_source.const import DATA_MEDIA_SOURCE_PLATFORMS from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -125,18 +125,18 @@ async def test_async_unresolve_media(hass: HomeAssistant) -> None: ) -async def test_browse_resolve_without_setup() -> None: +async def test_browse_resolve_without_setup(hass: HomeAssistant) -> None: """Test browse and resolve work without being setup.""" with pytest.raises(BrowseError): - await media_source.async_browse_media(Mock(data={}), None) + await media_source.async_browse_media(hass, None) with pytest.raises(BrowseError): await media_source.async_search_media( - Mock(data={}), None, SearchMediaQuery(search_query="test") + hass, None, SearchMediaQuery(search_query="test") ) with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(Mock(data={}), None, None) + await media_source.async_resolve_media(hass, None, None) async def test_async_search_media(hass: HomeAssistant) -> None: @@ -166,7 +166,11 @@ async def test_async_search_media(hass: HomeAssistant) -> None: async def test_async_search_media_not_supported(hass: HomeAssistant) -> None: """Test searching a source without search support raises a BrowseError.""" - hass.data[MEDIA_SOURCE_DATA] = {"plain": models.MediaSource("plain")} + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platform = AsyncMock( + return_value=models.MediaSource("plain") + ) with pytest.raises(BrowseError): await media_source.async_search_media( @@ -178,10 +182,11 @@ async def test_async_search_media_not_supported(hass: HomeAssistant) -> None: async def test_async_search_media_root_not_supported(hass: HomeAssistant) -> None: """Test searching the aggregate root of multiple sources is not supported.""" - hass.data[MEDIA_SOURCE_DATA] = { - "source_a": models.MediaSource("source_a"), - "source_b": models.MediaSource("source_b"), - } + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platforms = AsyncMock( + return_value={"source_a": models.MediaSource("source_a")} + ) with pytest.raises(BrowseError): await media_source.async_search_media( diff --git a/tests/helpers/test_integration_platform.py b/tests/helpers/test_integration_platform.py index 6784f1d9f083..0ad53bce417b 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -471,3 +471,25 @@ async def test_lazy_integration_platforms_concurrent(hass: HomeAssistant) -> Non assert results == [loaded_platform, loaded_platform] # The platform was imported and processed exactly once. assert processed == ["loaded"] + + +async def test_lazy_integration_platforms_async_process(hass: HomeAssistant) -> None: + """Test a coroutine process callback is awaited and its result cached.""" + loaded_platform = Mock() + mock_platform(hass, "loaded.platform_to_check", loaded_platform) + hass.config.components.add("loaded") + + processed: list[str] = [] + + async def _process_platform(hass: HomeAssistant, domain: str, platform: Any) -> Any: + processed.append(domain) + return platform + + platforms = LazyIntegrationPlatforms(hass, "platform_to_check", _process_platform) + + assert await platforms.async_get_platform("loaded") is loaded_platform + assert processed == ["loaded"] + + # The awaited result is cached, so a subsequent request does not reprocess. + assert await platforms.async_get_platform("loaded") is loaded_platform + assert processed == ["loaded"] From d79653b2883f72330625c6b563eb365298106384 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 5 Jul 2026 19:23:19 +0200 Subject: [PATCH 086/707] Remove deprecated classes from Service (#175611) --- homeassistant/helpers/service.py | 47 +------------------ tests/helpers/test_service.py | 78 -------------------------------- 2 files changed, 2 insertions(+), 123 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 5a9523099948..c9c56e4f09ab 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,13 +2,12 @@ import asyncio from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence -import dataclasses from enum import Enum from functools import cache, partial import inspect import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, cast, override +from typing import TYPE_CHECKING, Any, TypedDict, cast import voluptuous as vol @@ -60,7 +59,7 @@ from . import ( target as target_helpers, template, ) -from .deprecation import deprecated_class, deprecated_function, deprecated_hass_argument +from .deprecation import deprecated_hass_argument from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType @@ -223,33 +222,6 @@ class ServiceParams(TypedDict): target: dict | None -@deprecated_class( - "homeassistant.helpers.target.TargetSelection", - breaks_in_ha_version="2026.8", -) -class ServiceTargetSelector(target_helpers.TargetSelection): - """Class to hold a target selector for a service.""" - - def __init__(self, service_call: ServiceCall) -> None: - """Extract ids from service call data.""" - super().__init__(service_call.data) - - -@deprecated_class( - "homeassistant.helpers.target.SelectedEntities", - breaks_in_ha_version="2026.8", -) -class SelectedEntities(target_helpers.SelectedEntities): - """Class to hold the selected entities.""" - - @override - def log_missing( - self, missing_entities: set[str], logger: logging.Logger | None = None - ) -> None: - """Log about missing items.""" - super().log_missing(missing_entities, logger or _LOGGER) - - def call_from_config( hass: HomeAssistant, config: ConfigType, @@ -443,21 +415,6 @@ async def async_extract_entity_ids( return referenced.referenced | referenced.indirectly_referenced -@deprecated_function( - "homeassistant.helpers.target.async_extract_referenced_entity_ids", - breaks_in_ha_version="2026.8", -) -def async_extract_referenced_entity_ids( - hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True -) -> SelectedEntities: - """Extract referenced entity IDs from a service call.""" - target_selection = target_helpers.TargetSelection(service_call.data) - selected = target_helpers.async_extract_referenced_entity_ids( - hass, target_selection, expand_group - ) - return SelectedEntities(**dataclasses.asdict(selected)) - - @deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_config_entry_ids( service_call: ServiceCall, expand_group: bool = True diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index f6de318d4400..29c31d494777 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Callable, Generator, Iterable from copy import deepcopy -import dataclasses import io import threading from typing import Any @@ -2773,83 +2772,6 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: assert reloaded == unordered(["target1"]) -async def test_deprecated_service_target_selector_class(hass: HomeAssistant) -> None: - """Test that the deprecated ServiceTargetSelector class forwards correctly.""" - call = ServiceCall( - hass, - "test", - "test", - { - "entity_id": ["light.test", "switch.test"], - "area_id": "kitchen", - "device_id": ["device1", "device2"], - "floor_id": "first_floor", - "label_id": ["label1", "label2"], - }, - ) - selector = service.ServiceTargetSelector(call) - - assert selector.entity_ids == {"light.test", "switch.test"} - assert selector.area_ids == {"kitchen"} - assert selector.device_ids == {"device1", "device2"} - assert selector.floor_ids == {"first_floor"} - assert selector.label_ids == {"label1", "label2"} - assert selector.has_any_target is True - - -async def test_deprecated_selected_entities_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that the deprecated SelectedEntities class forwards correctly.""" - selected = service.SelectedEntities( - referenced={"entity.test"}, - indirectly_referenced=set(), - referenced_devices=set(), - referenced_areas=set(), - missing_devices={"missing_device"}, - missing_areas={"missing_area"}, - missing_floors={"missing_floor"}, - missing_labels={"missing_label"}, - ) - - missing_entities = {"entity.missing"} - selected.log_missing(missing_entities) - assert ( - "Referenced floors missing_floor, areas missing_area, " - "devices missing_device, entities entity.missing, " - "labels missing_label are missing or not currently available" in caplog.text - ) - - -async def test_deprecated_async_extract_referenced_entity_ids( - hass: HomeAssistant, -) -> None: - """Test deprecated async_extract_referenced_entity_ids forwards correctly.""" - from homeassistant.helpers import target # noqa: PLC0415 - - mock_selected = target.SelectedEntities( - referenced={"entity.test"}, - indirectly_referenced={"entity.indirect"}, - ) - with patch( - "homeassistant.helpers.target.async_extract_referenced_entity_ids", - return_value=mock_selected, - ) as mock_target_func: - call = ServiceCall(hass, "test", "test", {"entity_id": "light.test"}) - result = service.async_extract_referenced_entity_ids( - hass, call, expand_group=False - ) - - # Verify target helper was called with correct parameters - mock_target_func.assert_called_once() - args = mock_target_func.call_args - assert args[0][0] is hass - assert args[0][1].entity_ids == {"light.test"} - assert args[0][2] is False - - assert dataclasses.asdict(result) == dataclasses.asdict(mock_selected) - - async def test_register_platform_entity_service( hass: HomeAssistant, ) -> None: From 71c6bd21975f67ab0fd32e23c137316c391a3eed Mon Sep 17 00:00:00 2001 From: On Freund Date: Sun, 5 Jul 2026 20:49:40 +0300 Subject: [PATCH 087/707] Use serial port selector in Monoprice config flow (#175707) Co-authored-by: Claude Sonnet 5 --- homeassistant/components/monoprice/config_flow.py | 5 ++++- homeassistant/components/monoprice/manifest.json | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index 7ee75fa67a9f..81749cca18c5 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import SerialPortSelector from homeassistant.helpers.typing import VolDictType from .const import ( @@ -42,7 +43,9 @@ SOURCES = [ OPTIONS_FOR_DATA: VolDictType = {vol.Optional(source): str for source in SOURCES} -DATA_SCHEMA = vol.Schema({vol.Required(CONF_PORT): str, **OPTIONS_FOR_DATA}) +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_PORT): SerialPortSelector(), **OPTIONS_FOR_DATA} +) @callback diff --git a/homeassistant/components/monoprice/manifest.json b/homeassistant/components/monoprice/manifest.json index 7f4631f2aeef..25854086371b 100644 --- a/homeassistant/components/monoprice/manifest.json +++ b/homeassistant/components/monoprice/manifest.json @@ -3,6 +3,7 @@ "name": "Monoprice 6-Zone Amplifier", "codeowners": ["@etsinko", "@OnFreund"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/monoprice", "integration_type": "hub", "iot_class": "local_polling", From e74e857289686d28c446f6ad2369f00667ad968c Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 5 Jul 2026 20:44:41 +0200 Subject: [PATCH 088/707] Homee: fix covers not reacting correctly to commands (#175565) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/homee/cover.py | 22 +++++++++++----------- tests/components/homee/test_cover.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index 84ad604667de..b2f853dc792f 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -1,6 +1,6 @@ """The homee cover platform.""" -from enum import Enum +from enum import IntEnum import logging from typing import TYPE_CHECKING, Any, cast, override @@ -38,22 +38,22 @@ IS_CLOSED_ATTRIBUTES = [ ] -class HomeeCoverState(float, Enum): +class HomeeCoverState(IntEnum): """Open/closed states for covers in homee.""" - OPEN = 0.0 - CLOSED = 1.0 - STOPPED = 2.0 - OPENING = 3.0 - CLOSING = 4.0 + OPEN = 0 + CLOSED = 1 + STOPPED = 2 + OPENING = 3 + CLOSING = 4 -class HomeeSlatState(float, Enum): +class HomeeSlatState(IntEnum): """Slat states for covers in homee.""" - STOPPED = 0.0 - CLOSED = 1.0 - OPEN = 2.0 + STOPPED = 0 + CLOSED = 1 + OPEN = 2 def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index 98e2a681de72..f8d4b8abb584 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -83,6 +83,8 @@ async def test_open_close_stop_cover( calls = mock_homee.set_value.call_args_list for index, call in enumerate(calls): assert call[0] == (mock_homee.nodes[0].id, 1, index) + enum_value = call[0][2] + assert f"{enum_value}" == str(index) async def test_open_close_reverse_cover( @@ -114,6 +116,10 @@ async def test_open_close_reverse_cover( assert calls[0][0] == (mock_homee.nodes[0].id, 1, 1) # Open assert calls[1][0] == (mock_homee.nodes[0].id, 1, 0) # Close + for call in calls: + enum_value = call[0][2] + assert f"{enum_value}" in ("0", "1") + async def test_set_cover_position( hass: HomeAssistant, @@ -191,6 +197,8 @@ async def test_close_open_slats( calls = mock_homee.set_value.call_args_list for index, call in enumerate(calls): assert call[0] == (mock_homee.nodes[0].id, 2, index) + enum_value = call[0][2] + assert f"{enum_value}" == str(index) async def test_close_open_reversed_slats( @@ -229,6 +237,10 @@ async def test_close_open_reversed_slats( assert calls[0][0] == (mock_homee.nodes[0].id, 2, 2) # Close assert calls[1][0] == (mock_homee.nodes[0].id, 2, 1) # Open + for call in calls: + enum_value = call[0][2] + assert f"{enum_value}" in ("1", "2") + async def test_set_slat_position( hass: HomeAssistant, From 620e63c8f75f32218d1569ec8f1d7a51e00e1064 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 6 Jul 2026 03:12:33 +0200 Subject: [PATCH 089/707] Bump aioamazondevices to 14.2.0 (#175662) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index f8268b06bba1..82fe8b3ce90a 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==14.1.9"] + "requirements": ["aioamazondevices==14.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f52760f9a768..4a72e61aab46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==14.1.9 +aioamazondevices==14.2.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 12c10147182f6df05e4568255b30efa2e04a586b Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:23:25 +0200 Subject: [PATCH 090/707] Bump uiprotect to 15.4.2 (#175712) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 99ce3a71ec29..3bffc38f42cf 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.4.1"] + "requirements": ["uiprotect==15.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4a72e61aab46..9fd9528fda18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3240,7 +3240,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.4.1 +uiprotect==15.4.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From 9184e09dc91cbe2376946067299ca89d1275503c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 6 Jul 2026 03:26:24 +0200 Subject: [PATCH 091/707] Bump axis to v73 (#175717) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 3f9d4e842ea5..33a1aedc943a 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==72"], + "requirements": ["axis==74"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index 9fd9528fda18..7c1ea4a84c82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -606,7 +606,7 @@ av==17.0.1 avea==1.8.0 # homeassistant.components.axis -axis==72 +axis==74 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 From 3b742537fff1bfa004ae86b82d219fc1102e1608 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 6 Jul 2026 05:01:29 +0200 Subject: [PATCH 092/707] Bump python-bsblan to 6.1.6 (#175711) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 6a61a9967cff..2403b38a5274 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==6.1.5"], + "requirements": ["python-bsblan==6.1.6"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/requirements_all.txt b/requirements_all.txt index 7c1ea4a84c82..4c2da8f5b44d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2619,7 +2619,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==6.1.5 +python-bsblan==6.1.6 # homeassistant.components.citybikes python-citybikes==0.3.3 From ac5759b86c482f58842048141fab79aa25c30017 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 5 Jul 2026 23:50:57 -0700 Subject: [PATCH 093/707] Bump python-roborock to 5.26.0 (#175734) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 89559924ee30..be94876e0dd8 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.25.0", + "python-roborock==5.26.0", "vacuum-map-parser-roborock==0.1.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c2da8f5b44d..5cd42feb0f46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2725,7 +2725,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==5.25.0 +python-roborock==5.26.0 # homeassistant.components.smarttub python-smarttub==0.0.47 From 023264618093d66f79e141e0ed44eb5dbf391c01 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 5 Jul 2026 23:51:01 -0700 Subject: [PATCH 094/707] Bump pyrainbird to 6.5.0 (#175723) --- homeassistant/components/rainbird/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 3f2aaa86c03c..65a5c6e68f2d 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==6.3.1"] + "requirements": ["pyrainbird==6.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5cd42feb0f46..898d400e78da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2490,7 +2490,7 @@ pyqwikswitch==0.93 pyrail==0.4.1 # homeassistant.components.rainbird -pyrainbird==6.3.1 +pyrainbird==6.5.0 # homeassistant.components.playstation_network pyrate-limiter==4.4.0 From db48fdf167da21e51d25593239137e889bd79c9a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 6 Jul 2026 18:19:05 +1000 Subject: [PATCH 095/707] Fix teslemetry manifest loggers to actual module names (#175742) --- homeassistant/components/teslemetry/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index d56b3c6a30e0..18028f5f5ac7 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["tesla-fleet-api"], + "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", "requirements": ["tesla-fleet-api==1.5.2", "teslemetry-stream==0.9.1"] } From cf3562b7e6db393190cc9a9013aaf36f67155d49 Mon Sep 17 00:00:00 2001 From: Vincent Courcelle <2070309+tubededentifrice@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:33:43 +0400 Subject: [PATCH 096/707] Add Roborock Q10 switch entities (#175731) --- homeassistant/components/roborock/icons.json | 6 + .../components/roborock/strings.json | 6 + homeassistant/components/roborock/switch.py | 98 +++++++++++- tests/components/roborock/conftest.py | 73 ++++++--- .../roborock/snapshots/test_switch.ambr | 151 ++++++++++++++++++ tests/components/roborock/test_switch.py | 140 ++++++++++++++-- 6 files changed, 429 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index 71018ee9e14e..76ba0b47a925 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -123,12 +123,18 @@ } }, "switch": { + "button_light": { + "default": "mdi:lightbulb" + }, "child_lock": { "default": "mdi:account-lock" }, "dnd_switch": { "default": "mdi:bell-cancel" }, + "dust_collection": { + "default": "mdi:delete" + }, "off_peak_switch": { "default": "mdi:power-plug" }, diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index f301f3b50c40..7f4b524dc088 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -604,12 +604,18 @@ } }, "switch": { + "button_light": { + "name": "Indicator light" + }, "child_lock": { "name": "Child lock" }, "dnd_switch": { "name": "Do not disturb" }, + "dust_collection": { + "name": "Dust collection" + }, "off_peak_switch": { "name": "Off-peak charging" }, diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index c1823a763f0c..42c700bcc8cd 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -6,18 +6,24 @@ import logging from typing import Any, override from roborock.devices.traits.b01 import Q10PropertiesApi -from roborock.devices.traits.b01.q10 import DoNotDisturbTrait +from roborock.devices.traits.b01.q10 import ( + ButtonLightTrait, + ChildLockTrait, + DoNotDisturbTrait, + DustCollectionTrait, +) from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.common import RoborockSwitchBase from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.const import EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from .const import DOMAIN from .coordinator import ( @@ -86,11 +92,14 @@ class RoborockSwitchDescriptionA01(SwitchEntityDescription): data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol +type Q10SwitchTrait = ChildLockTrait | DoNotDisturbTrait | DustCollectionTrait + + @dataclass(frozen=True, kw_only=True) class RoborockSwitchDescriptionQ10(SwitchEntityDescription): """Class to describe a Roborock Q10 switch entity.""" - trait: Callable[[Q10PropertiesApi], DoNotDisturbTrait | None] + trait: Callable[[Q10PropertiesApi], Q10SwitchTrait | None] A01_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionA01] = [ @@ -109,7 +118,19 @@ Q10_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionQ10] = [ translation_key="dnd_switch", entity_category=EntityCategory.CONFIG, trait=lambda traits: traits.do_not_disturb, - ) + ), + RoborockSwitchDescriptionQ10( + key="child_lock", + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + trait=lambda traits: traits.child_lock, + ), + RoborockSwitchDescriptionQ10( + key="dust_collection", + translation_key="dust_collection", + entity_category=EntityCategory.CONFIG, + trait=lambda traits: traits.dust_collection, + ), ] @@ -159,6 +180,13 @@ async def async_setup_entry( for description in Q10_SWITCH_DESCRIPTIONS if (q10_trait := description.trait(coordinator.api)) is not None ) + entities.append( + RoborockSwitchQ10ButtonLight( + f"button_light_{coordinator.duid_slug}", + coordinator, + coordinator.api.button_light, + ) + ) async_add_entities(entities) for coordinator in coordinators.values(): @@ -290,7 +318,7 @@ class RoborockSwitchQ10(RoborockCoordinatedEntityB01Q10, SwitchEntity): unique_id: str, coordinator: RoborockB01Q10UpdateCoordinator, description: RoborockSwitchDescriptionQ10, - trait: DoNotDisturbTrait, + trait: Q10SwitchTrait, ) -> None: """Initialize the entity.""" self.entity_description = description @@ -330,3 +358,63 @@ class RoborockSwitchQ10(RoborockCoordinatedEntityB01Q10, SwitchEntity): def is_on(self) -> bool | None: """Return True if entity is on.""" return self._trait.is_on + + +class RoborockSwitchQ10ButtonLight( + RoborockCoordinatedEntityB01Q10, SwitchEntity, RestoreEntity +): + """A class to toggle the indicator / button light of a Roborock Q10 device. + + The device does not report the light state, so the switch is write-only + and assumes the state of the last successful command, restored across + restarts. + """ + + _attr_assumed_state = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "button_light" + + def __init__( + self, + unique_id: str, + coordinator: RoborockB01Q10UpdateCoordinator, + trait: ButtonLightTrait, + ) -> None: + """Initialize the entity.""" + self._trait = trait + super().__init__(unique_id, coordinator) + + @override + async def async_added_to_hass(self) -> None: + """Restore the last assumed state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None and ( + last_state.state in (STATE_ON, STATE_OFF) + ): + self._attr_is_on = last_state.state == STATE_ON + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + try: + await self._trait.disable() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + self._attr_is_on = False + self.async_write_ha_state() + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + try: + await self._trait.enable() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + self._attr_is_on = True + self.async_write_ha_state() diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 061144a6a47c..0a1d4aeb3ec0 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -168,6 +168,46 @@ def create_b01_q7_trait() -> Mock: return b01_trait +def attach_update_listeners(trait: Mock) -> Callable[[], None]: + """Give a mock Q10 trait working add_update_listener support. + + Returns a callback that notifies all currently registered listeners, + mimicking a push update from the device. + """ + listeners: list[Callable[[], None]] = [] + + def add_update_listener(cb: Callable[[], None]) -> Callable[[], None]: + listeners.append(cb) + return lambda: listeners.remove(cb) + + trait.add_update_listener = Mock(side_effect=add_update_listener) + + def notify() -> None: + for cb in listeners: + cb() + + return notify + + +def make_q10_switch_trait( + on_change: Callable[[bool], None] | None = None, +) -> AsyncMock: + """Create a mock Q10 switch-like trait (is_on / enable / disable).""" + trait = AsyncMock() + trait.is_on = True + notify = attach_update_listeners(trait) + + def _set(value: bool) -> None: + trait.is_on = value + if on_change is not None: + on_change(value) + notify() + + trait.enable = AsyncMock(side_effect=lambda: _set(True)) + trait.disable = AsyncMock(side_effect=lambda: _set(False)) + return trait + + def create_b01_q10_trait() -> Mock: """Create B01 Q10 trait for Q10 devices. @@ -188,31 +228,16 @@ def create_b01_q10_trait() -> Mock: q10_trait.vacuum = AsyncMock() q10_trait.command = AsyncMock() q10_trait.refresh = AsyncMock() - q10_trait.do_not_disturb = AsyncMock() - q10_trait.do_not_disturb.is_on = True - _dnd_listeners: list[Callable[[], None]] = [] + q10_trait.do_not_disturb = make_q10_switch_trait( + on_change=lambda value: setattr(q10_trait.status, "not_disturb", value) + ) + q10_trait.child_lock = make_q10_switch_trait() + q10_trait.dust_collection = make_q10_switch_trait() + # The button light trait is write-only (enable/disable, no is_on) + q10_trait.button_light = Mock(spec=["enable", "disable"]) + q10_trait.button_light.enable = AsyncMock() + q10_trait.button_light.disable = AsyncMock() - def _dnd_add_update_listener(cb: Callable[[], None]) -> Callable[[], None]: - _dnd_listeners.append(cb) - return lambda: _dnd_listeners.remove(cb) - - q10_trait.do_not_disturb.add_update_listener = Mock( - side_effect=_dnd_add_update_listener - ) - q10_trait.do_not_disturb.enable = AsyncMock( - side_effect=lambda: ( - setattr(q10_trait.do_not_disturb, "is_on", True), - setattr(q10_trait.status, "not_disturb", True), - [cb() for cb in _dnd_listeners], - ) - ) - q10_trait.do_not_disturb.disable = AsyncMock( - side_effect=lambda: ( - setattr(q10_trait.do_not_disturb, "is_on", False), - setattr(q10_trait.status, "not_disturb", False), - [cb() for cb in _dnd_listeners], - ) - ) q10_trait.map = Mock() q10_trait.map.rooms = [ Q10Room(id=9, raw_name="rr_bedroom", pixel_value=36, pixel_count=100), diff --git a/tests/components/roborock/snapshots/test_switch.ambr b/tests/components/roborock/snapshots/test_switch.ambr index 70eda195a840..addb6fdde78a 100644 --- a/tests/components/roborock/snapshots/test_switch.ambr +++ b/tests/components/roborock/snapshots/test_switch.ambr @@ -1,4 +1,54 @@ # serializer version: 1 +# name: test_switches[switch.roborock_q10_s5_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_q10_s5_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'child_lock_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_q10_s5_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Roborock Q10 S5+ Child lock', + }), + 'context': , + 'entity_id': 'switch.roborock_q10_s5_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch.roborock_q10_s5_do_not_disturb-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -49,6 +99,107 @@ 'state': 'on', }) # --- +# name: test_switches[switch.roborock_q10_s5_dust_collection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_q10_s5_dust_collection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dust collection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dust collection', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dust_collection', + 'unique_id': 'dust_collection_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_q10_s5_dust_collection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Roborock Q10 S5+ Dust collection', + }), + 'context': , + 'entity_id': 'switch.roborock_q10_s5_dust_collection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_q10_s5_indicator_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_q10_s5_indicator_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Indicator light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_light', + 'unique_id': 'button_light_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_q10_s5_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'Roborock Q10 S5+ Indicator light', + }), + 'context': , + 'entity_id': 'switch.roborock_q10_s5_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_switches[switch.roborock_s7_2_do_not_disturb-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index a2c1d744e45e..7e516f7403a2 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -10,15 +10,20 @@ from roborock.roborock_message import RoborockZeoProtocol from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .conftest import FakeDevice -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache, + snapshot_platform, +) @pytest.fixture @@ -257,14 +262,22 @@ async def test_a01_switch_unknown_state( assert state.state == "unknown" -async def test_q10_do_not_disturb_switch_success( +@pytest.mark.parametrize( + ("entity_id", "trait_name"), + [ + ("switch.roborock_q10_s5_do_not_disturb", "do_not_disturb"), + ("switch.roborock_q10_s5_child_lock", "child_lock"), + ("switch.roborock_q10_s5_dust_collection", "dust_collection"), + ], +) +async def test_q10_switch_success( hass: HomeAssistant, setup_entry: MockConfigEntry, fake_q10_vacuum: FakeDevice, + entity_id: str, + trait_name: str, ) -> None: - """Test turning Q10 Do Not Disturb on and off.""" - entity_id = "switch.roborock_q10_s5_do_not_disturb" - + """Test turning Q10 switch entities on and off.""" assert hass.states.get(entity_id) is not None await hass.services.async_call( @@ -290,25 +303,34 @@ async def test_q10_do_not_disturb_switch_success( assert state.state == "on" assert fake_q10_vacuum.b01_q10_properties is not None - assert fake_q10_vacuum.b01_q10_properties.do_not_disturb.enable.call_count == 1 - assert fake_q10_vacuum.b01_q10_properties.do_not_disturb.disable.call_count == 1 + trait = getattr(fake_q10_vacuum.b01_q10_properties, trait_name) + trait.enable.assert_awaited_once() + trait.disable.assert_awaited_once() -async def test_q10_do_not_disturb_switch_failure( +@pytest.mark.parametrize( + ("entity_id", "trait_name"), + [ + ("switch.roborock_q10_s5_do_not_disturb", "do_not_disturb"), + ("switch.roborock_q10_s5_child_lock", "child_lock"), + ("switch.roborock_q10_s5_dust_collection", "dust_collection"), + ], +) +async def test_q10_switch_failure( hass: HomeAssistant, setup_entry: MockConfigEntry, fake_q10_vacuum: FakeDevice, + entity_id: str, + trait_name: str, ) -> None: - """Test a failure while updating Q10 Do Not Disturb.""" - entity_id = "switch.roborock_q10_s5_do_not_disturb" + """Test a failure while updating a Q10 switch.""" assert fake_q10_vacuum.b01_q10_properties is not None - fake_q10_vacuum.b01_q10_properties.do_not_disturb.enable.side_effect = ( - roborock.exceptions.RoborockTimeout - ) + trait = getattr(fake_q10_vacuum.b01_q10_properties, trait_name) + trait.enable.side_effect = roborock.exceptions.RoborockTimeout assert hass.states.get(entity_id) is not None - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -316,3 +338,89 @@ async def test_q10_do_not_disturb_switch_failure( blocking=True, target={"entity_id": entity_id}, ) + + +async def test_q10_button_light_switch( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test the Q10 write-only indicator light switch assumes its state.""" + entity_id = "switch.roborock_q10_s5_indicator_light" + + # The device never reports the light state, so it starts unknown + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.button_light.enable.assert_awaited_once() + fake_q10_vacuum.b01_q10_properties.button_light.disable.assert_awaited_once() + + +async def test_q10_button_light_switch_restore_state( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test the Q10 indicator light restores its assumed state after a restart.""" + entity_id = "switch.roborock_q10_s5_indicator_light" + mock_restore_cache(hass, (State(entity_id, STATE_ON),)) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + +async def test_q10_button_light_switch_failure( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test the Q10 indicator light keeps its state on a failed command.""" + entity_id = "switch.roborock_q10_s5_indicator_light" + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.button_light.enable.side_effect = ( + roborock.exceptions.RoborockTimeout + ) + + assert hass.states.get(entity_id) is not None + + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + + # The failed command must not flip the assumed state + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN From 73ef399d81ac40766b5e3c504fc89affe51eb579 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 6 Jul 2026 11:34:45 +0200 Subject: [PATCH 097/707] Add restart button to Alexa Devices (#175607) --- .../components/alexa_devices/button.py | 57 +++++++++++++++++-- tests/components/alexa_devices/const.py | 4 +- .../alexa_devices/snapshots/test_button.ambr | 51 +++++++++++++++++ .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_services.ambr | 3 + tests/components/alexa_devices/test_button.py | 20 ++++++- 6 files changed, 128 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py index d4d56033c45a..a9712201ca50 100644 --- a/homeassistant/components/alexa_devices/button.py +++ b/homeassistant/components/alexa_devices/button.py @@ -1,20 +1,43 @@ """Support for buttons.""" -from typing import override +from dataclasses import dataclass +from typing import Final, override -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call -from .entity import AmazonServiceEntity +from .entity import AmazonEntity, AmazonServiceEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class AmazonButtonEntityDescription(ButtonEntityDescription): + """Amazon Devices button entity description.""" + + capability: str + + +DEVICE_BUTTONS: Final = { + AmazonButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + capability="ALEXA_DEVICE_REBOOT", + ), +} + + async def async_setup_entry( hass: HomeAssistant, entry: AmazonConfigEntry, @@ -24,8 +47,9 @@ async def async_setup_entry( coordinator = entry.runtime_data known_routines: set[str] = set() + known_devices: set[str] = set() - def _check_routines() -> None: + def _check_routines_devices() -> None: current_routines = set(coordinator.api.routines) new_routines = current_routines - known_routines if new_routines: @@ -34,8 +58,19 @@ async def async_setup_entry( AmazonRoutineButton(coordinator, routine) for routine in new_routines ) - _check_routines() - entry.async_on_unload(coordinator.async_add_listener(_check_routines)) + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonDeviceButton(coordinator, serial_num, button_desc) + for button_desc in DEVICE_BUTTONS + for serial_num in new_devices + if button_desc.capability in coordinator.data[serial_num].capabilities + ) + + _check_routines_devices() + entry.async_on_unload(coordinator.async_add_listener(_check_routines_devices)) class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): @@ -54,3 +89,13 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): """Handle button press action.""" async with alexa_api_call(self.coordinator): await self.coordinator.api.call_routine(self._routine) + + +class AmazonDeviceButton(AmazonEntity, ButtonEntity): + """Button entity for Alexa device.""" + + @override + async def async_press(self) -> None: + """Handle button press action.""" + async with alexa_api_call(self.coordinator): + await self.coordinator.api.restart_device(self.device) diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 0c49ce7864fa..28219ec758dc 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -23,7 +23,7 @@ TEST_DEVICE_1_SN = "echo_test_serial_number" TEST_DEVICE_1_ID = "echo_test_device_id" TEST_DEVICE_1 = AmazonDevice( account_name="Echo Test", - capabilities=["AUDIO_PLAYER", "MICROPHONE"], + capabilities=["AUDIO_PLAYER", "MICROPHONE", "ALEXA_DEVICE_REBOOT"], device_family="mine", device_type="echo", household_device=False, @@ -87,7 +87,7 @@ TEST_DEVICE_1 = AmazonDevice( TEST_DEVICE_2_SN = "echo_test_2_serial_number" TEST_DEVICE_2 = AmazonDevice( account_name="Echo Test 2", - capabilities=["AUDIO_PLAYER", "MICROPHONE"], + capabilities=["AUDIO_PLAYER", "MICROPHONE", "ALEXA_DEVICE_REBOOT"], device_family="mine", device_type="echo", household_device=True, diff --git a/tests/components/alexa_devices/snapshots/test_button.ambr b/tests/components/alexa_devices/snapshots/test_button.ambr index b2dbec7a96f2..8482acd11f60 100644 --- a/tests/components/alexa_devices/snapshots/test_button.ambr +++ b/tests/components/alexa_devices/snapshots/test_button.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_all_entities[button.echo_test_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.echo_test_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'echo_test_serial_number-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.echo_test_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'Echo Test Restart', + }), + 'context': , + 'entity_id': 'button.echo_test_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[button.fake_email_gmail_com_test_routine-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 8c30470005f6..06073892f9ea 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -5,6 +5,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'device cluster members': dict({ 'echo_test_serial_number': 'echo_test_device_id', @@ -43,6 +44,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'device cluster members': dict({ 'echo_test_serial_number': 'echo_test_device_id', diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr index cf333f088247..511de9b64ff3 100644 --- a/tests/components/alexa_devices/snapshots/test_services.ambr +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -7,6 +7,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'communication_settings': dict({ 'announcements': 'ON', @@ -83,6 +84,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'communication_settings': dict({ 'announcements': 'ON', @@ -159,6 +161,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'communication_settings': dict({ 'announcements': 'ON', diff --git a/tests/components/alexa_devices/test_button.py b/tests/components/alexa_devices/test_button.py index a1bba9b7e1a9..265ae612b208 100644 --- a/tests/components/alexa_devices/test_button.py +++ b/tests/components/alexa_devices/test_button.py @@ -14,7 +14,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify from . import setup_integration -from .const import TEST_USERNAME +from .const import TEST_DEVICE_1, TEST_USERNAME from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -94,3 +94,21 @@ async def test_dynamic_entities( for routine in set(initial_routine) - set(updated_routines): entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" assert hass.states.get(entity_id) is None + + +async def test_restart_button( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test restart button.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.{slugify(TEST_DEVICE_1.account_name)}_restart"}, + blocking=True, + ) + mock_amazon_devices_client.restart_device.assert_called_once() From a8859a8383104cd9bc2bdcf9bb5097fdba149aeb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:03:49 +0200 Subject: [PATCH 098/707] Use Attribute enum in input_datetime (#175737) --- homeassistant/components/input_datetime/reproduce_state.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/input_datetime/reproduce_state.py b/homeassistant/components/input_datetime/reproduce_state.py index ea087f7d8369..f01ab5ed3cc4 100644 --- a/homeassistant/components/input_datetime/reproduce_state.py +++ b/homeassistant/components/input_datetime/reproduce_state.py @@ -9,7 +9,8 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, HomeAssistant, State from homeassistant.util import dt as dt_util -from . import ATTR_DATE, ATTR_DATETIME, ATTR_TIME, CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN +from . import ATTR_DATE, ATTR_DATETIME, ATTR_TIME, DOMAIN +from .const import InputDatetimeEntityCapabilityAttribute _LOGGER = logging.getLogger(__name__) @@ -44,8 +45,8 @@ async def _async_reproduce_state( _LOGGER.warning("Unable to find entity %s", state.entity_id) return - has_time = cur_state.attributes.get(CONF_HAS_TIME) - has_date = cur_state.attributes.get(CONF_HAS_DATE) + has_time = cur_state.attributes.get(InputDatetimeEntityCapabilityAttribute.HAS_TIME) + has_date = cur_state.attributes.get(InputDatetimeEntityCapabilityAttribute.HAS_DATE) if not ( (is_valid_datetime(state.state) and has_date and has_time) From e93b9e7b64cedf5b448074e0af934b350e043b6b Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:05:26 +0300 Subject: [PATCH 099/707] Pump pysaunum to 0.7.0 (#175680) --- homeassistant/components/saunum/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/saunum/snapshots/test_number.ambr | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/saunum/manifest.json b/homeassistant/components/saunum/manifest.json index d65394d01ae6..f3458724d6eb 100644 --- a/homeassistant/components/saunum/manifest.json +++ b/homeassistant/components/saunum/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pysaunum"], "quality_scale": "platinum", - "requirements": ["pysaunum==0.6.0"] + "requirements": ["pysaunum==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 898d400e78da..932e6a5cb19f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2520,7 +2520,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.saunum -pysaunum==0.6.0 +pysaunum==0.7.0 # homeassistant.components.schlage pyschlage==2025.9.0 diff --git a/tests/components/saunum/snapshots/test_number.ambr b/tests/components/saunum/snapshots/test_number.ambr index 5e9672153603..70f983cf5432 100644 --- a/tests/components/saunum/snapshots/test_number.ambr +++ b/tests/components/saunum/snapshots/test_number.ambr @@ -6,7 +6,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : 30, + : 15, : 1, : , : 1, @@ -46,7 +46,7 @@ 'attributes': ReadOnlyDict({ : 'duration', : 'Saunum Leil Fan duration', - : 30, + : 15, : 1, : , : 1, From fd3318bb0d78fe0e85f2e780c46fcbe183d0197b Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:07:50 +0300 Subject: [PATCH 100/707] Add translation support for connection errors in Saunum integration (#175675) --- homeassistant/components/saunum/__init__.py | 7 +++++-- homeassistant/components/saunum/strings.json | 3 +++ tests/components/saunum/test_init.py | 4 ++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index 8dca2541ab14..7c9578ea9bec 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -39,8 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> try: client = await SaunumClient.create(host) except (SaunumConnectionError, SaunumTimeoutError) as exc: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host}, + ) from exc entry.async_on_unload(client.async_close) diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index 4e3645b66991..9b480f6a1eb0 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -87,6 +87,9 @@ } }, "exceptions": { + "cannot_connect": { + "message": "Error connecting to {host}" + }, "communication_error": { "message": "Communication error with sauna control unit" }, diff --git a/tests/components/saunum/test_init.py b/tests/components/saunum/test_init.py index 3a41acf57b5b..8de4866dbedf 100644 --- a/tests/components/saunum/test_init.py +++ b/tests/components/saunum/test_init.py @@ -44,6 +44,10 @@ async def test_async_setup_entry_connection_failed( assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.error_reason_translation_key == "cannot_connect" + assert mock_config_entry.error_reason_translation_placeholders == { + "host": mock_config_entry.data["host"], + } @pytest.mark.usefixtures("init_integration") From 6b64550cf85a6b98650cc759659a97773d25b6a2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 12:20:47 +0200 Subject: [PATCH 101/707] Remove battery prop from template vacuum (#175681) --- .../components/template/strings.json | 4 - homeassistant/components/template/vacuum.py | 55 +----------- tests/components/template/test_vacuum.py | 87 +------------------ 3 files changed, 5 insertions(+), 141 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 5e550532e189..6de00e8fdc77 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -589,10 +589,6 @@ "config_format_triggers": { "description": "A trigger template configuration needs a trigger and at least one domain when defining an entity. This will be an configuration validation error in Home Assistant Core 2026.5.\n\n Please remove the orphaned trigger from the configuration.\n\n```\n{config}\n```", "title": "Incomplete template configuration" - }, - "deprecated_battery_level": { - "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name}).", - "title": "Deprecated battery level option in {entity_name}" } }, "options": { diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 6a8cf8a5bfd6..f0412fb90373 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -23,12 +23,11 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -49,7 +48,6 @@ from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) -CONF_BATTERY_LEVEL = "battery_level" CONF_CLEAN_SEGMENTS = "clean_segments" CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_LIST = "fan_speeds" @@ -75,7 +73,6 @@ CLEAN_AREA_GROUP = "clean_area_group" VACUUM_COMMON_SCHEMA = vol.Schema( { - vol.Optional(CONF_BATTERY_LEVEL): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, vol.Optional(CONF_STATE): cv.template, @@ -164,26 +161,6 @@ def async_create_preview_vacuum( ) -def create_issue( - hass: HomeAssistant, supported_features: int, name: str, entity_id: str -) -> None: - """Create the battery_level issue.""" - if supported_features & VacuumEntityFeature.BATTERY: - key = "deprecated_battery_level" - ir.async_create_issue( - hass, - DOMAIN, - f"{key}_{entity_id}", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=key, - translation_placeholders={ - "entity_name": name, - "entity_id": entity_id, - }, - ) - - def validate_segments( entity: AbstractTemplateVacuum, option: str, @@ -266,11 +243,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self, CONF_FAN_SPEED, self._attr_fan_speed_list ), ) - self.setup_template( - CONF_BATTERY_LEVEL, - "_attr_battery_level", - template_validators.number(self, CONF_BATTERY_LEVEL, 0.0, 100.0), - ) self.setup_template( CONF_SEGMENTS, @@ -283,9 +255,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): VacuumEntityFeature.START | VacuumEntityFeature.STATE ) - if CONF_BATTERY_LEVEL in self._templates: - self._attr_supported_features |= VacuumEntityFeature.BATTERY - for action_id, supported_feature in ( (SERVICE_START, 0), (SERVICE_PAUSE, VacuumEntityFeature.PAUSE), @@ -419,17 +388,6 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): assert name is not None AbstractTemplateVacuum.__init__(self, name, config) - @override - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - create_issue( - self.hass, - self._attr_supported_features, - self._attr_name or DEFAULT_NAME, - self.entity_id, - ) - class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): """Vacuum entity based on trigger data.""" @@ -446,14 +404,3 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): TriggerEntity.__init__(self, hass, coordinator, config) self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) AbstractTemplateVacuum.__init__(self, name, config) - - @override - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - create_issue( - self.hass, - self._attr_supported_features, - self._attr_name or DEFAULT_NAME, - self.entity_id, - ) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 8528814f7f72..85eb83f01885 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -9,7 +9,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import template, vacuum from homeassistant.components.template.vacuum import CONF_CLEAN_SEGMENTS, CONF_SEGMENTS from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, Segment, VacuumActivity, @@ -86,14 +85,12 @@ TEMPLATE_VACUUM_ACTIONS = { def _verify( hass: HomeAssistant, expected_state: str, - expected_battery_level: int | None = None, expected_fan_speed: str | None = None, ) -> None: """Verify vacuum's state and speed.""" state = hass.states.get(TEST_VACUUM.entity_id) attributes = state.attributes assert state.state == expected_state - assert attributes.get(ATTR_BATTERY_LEVEL) == expected_battery_level assert attributes.get(ATTR_FAN_SPEED) == expected_fan_speed @@ -192,88 +189,74 @@ async def setup_attributes_state_vacuum( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "state_template", "extra_config", "parm1", "parm2"), + ("style", "state_template", "extra_config", "parm1"), [ ( ConfigurationStyle.MODERN, None, {"start": {"service": "script.vacuum_start"}}, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.TRIGGER, None, {"start": {"service": "script.vacuum_start"}}, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.MODERN, "{{ 'cleaning' }}", { - "battery_level": "{{ 100 }}", "start": {"service": "script.vacuum_start"}, }, VacuumActivity.CLEANING, - 100, ), ( ConfigurationStyle.TRIGGER, "{{ 'cleaning' }}", { - "battery_level": "{{ 100 }}", "start": {"service": "script.vacuum_start"}, }, VacuumActivity.CLEANING, - 100, ), ( ConfigurationStyle.MODERN, "{{ 'abc' }}", { - "battery_level": "{{ 101 }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.TRIGGER, "{{ 'abc' }}", { - "battery_level": "{{ 101 }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.MODERN, "{{ this_function_does_not_exist() }}", { - "battery_level": "{{ this_function_does_not_exist() }}", "fan_speed": "{{ this_function_does_not_exist() }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNAVAILABLE, - None, ), ( ConfigurationStyle.TRIGGER, "{{ this_function_does_not_exist() }}", { - "battery_level": "{{ this_function_does_not_exist() }}", "fan_speed": "{{ this_function_does_not_exist() }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNAVAILABLE, - None, ), ], ) @pytest.mark.usefixtures("setup_base_vacuum") -async def test_valid_configs(hass: HomeAssistant, count, parm1, parm2) -> None: +async def test_valid_configs(hass: HomeAssistant, count, parm1) -> None: """Test: configs.""" # Ensure trigger entity templates are rendered @@ -281,7 +264,7 @@ async def test_valid_configs(hass: HomeAssistant, count, parm1, parm2) -> None: await hass.async_block_till_done() assert len(hass.states.async_all("vacuum")) == count - _verify(hass, parm1, parm2) + _verify(hass, parm1) @pytest.mark.parametrize("count", [0]) @@ -302,68 +285,6 @@ async def test_invalid_configs(hass: HomeAssistant, count) -> None: assert len(hass.states.async_all("vacuum")) == count -@pytest.mark.parametrize( - ("count", "state_template", "extra_config"), - [(1, "{{ states('sensor.test_state') }}", {})], -) -@pytest.mark.parametrize( - ("style", "attribute"), - [ - (ConfigurationStyle.MODERN, "battery_level"), - (ConfigurationStyle.TRIGGER, "battery_level"), - ], -) -@pytest.mark.parametrize( - ("attribute_template", "expected"), - [ - ("{{ '0' }}", 0), - ("{{ 100 }}", 100), - ("{{ 101 }}", None), - ("{{ -1 }}", None), - ("{{ 'foo' }}", None), - ], -) -@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_battery_level_template( - hass: HomeAssistant, expected: int | None -) -> None: - """Test templates with values from other entities.""" - await async_trigger(hass, TEST_STATE_ENTITY_ID) - _verify(hass, STATE_UNKNOWN, expected) - - -@pytest.mark.parametrize( - ("count", "state_template", "extra_config", "attribute_template"), - [(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")], -) -@pytest.mark.parametrize( - ("style", "attribute", "issue_count"), - [ - (ConfigurationStyle.MODERN, "battery_level", 1), - (ConfigurationStyle.TRIGGER, "battery_level", 1), - ], -) -@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_battery_level_template_repair( - hass: HomeAssistant, - issue_count: int, - issue_registry: ir.IssueRegistry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test battery_level template raises issue.""" - await async_trigger(hass, TEST_STATE_ENTITY_ID, VacuumActivity.DOCKED) - - assert len(issue_registry.issues) == issue_count - issue = issue_registry.async_get_issue( - "template", f"deprecated_battery_level_{TEST_VACUUM.entity_id}" - ) - assert issue.domain == "template" - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders["entity_name"] == TEST_VACUUM.object_id - assert issue.translation_placeholders["entity_id"] == TEST_VACUUM.entity_id - assert "Detected that integration 'template' is setting the" not in caplog.text - - @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ @@ -396,7 +317,7 @@ async def test_battery_level_template_repair( async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: """Test templates with values from other entities.""" await async_trigger(hass, TEST_STATE_ENTITY_ID) - _verify(hass, STATE_UNKNOWN, None, expected) + _verify(hass, STATE_UNKNOWN, expected) @pytest.mark.parametrize( From f4ca7406fb278343c5cfdefd13bf318d31105ad2 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Mon, 6 Jul 2026 12:22:20 +0200 Subject: [PATCH 102/707] Mark IQS device rules done/exempt (#175200) Co-authored-by: Josef Zweck --- homeassistant/components/teltonika/quality_scale.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teltonika/quality_scale.yaml b/homeassistant/components/teltonika/quality_scale.yaml index 96e783ea3585..eb563705413f 100644 --- a/homeassistant/components/teltonika/quality_scale.yaml +++ b/homeassistant/components/teltonika/quality_scale.yaml @@ -59,7 +59,9 @@ rules: docs-supported-functions: todo docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: + status: exempt + comment: Integration only has static hardware devices, single device per config entry entity-category: todo entity-device-class: done entity-disabled-by-default: todo @@ -68,7 +70,9 @@ rules: icon-translations: todo reconfiguration-flow: todo repair-issues: todo - stale-devices: todo + stale-devices: + status: exempt + comment: Integration only has static hardware devices, single device per config entry # Platinum async-dependency: done From b1614d3ac8b4e5eff4ed0fff6000e663ddaa84d5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 12:28:47 +0200 Subject: [PATCH 103/707] Remove deprecated battery level prop from Neato vacuum (#175684) --- homeassistant/components/neato/vacuum.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 699c621a09fc..0cc545cf2711 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -63,8 +63,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Representation of a Neato Connected Vacuum.""" _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.PAUSE + VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -172,8 +171,6 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): self._attr_activity = VacuumActivity.ERROR self._status_state = ERRORS.get(self._state["error"]) - self._attr_battery_level = self._state["details"]["charge"] - if self._mapdata is None or not self._mapdata.get(self._robot_serial, {}).get( "maps", [] ): From d498dbd8f7f113537fa06192ace6755a6fac5110 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 12:30:46 +0200 Subject: [PATCH 104/707] Remove previously deprecated battery props from Romy (#175686) --- homeassistant/components/romy/vacuum.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index 4bf58d7473d8..1e5815b376a3 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -38,8 +38,7 @@ FAN_SPEEDS: list[str] = [ # Commonly supported features SUPPORT_ROMY_ROBOT = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.STATE | VacuumEntityFeature.START | VacuumEntityFeature.STOP @@ -76,7 +75,6 @@ class RomyVacuumEntity(RomyEntity, StateVacuumEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed] - self._attr_battery_level = self.romy.battery_level if (status := self.romy.status) is None: self._attr_activity = None self.async_write_ha_state() From 16018ff0bea09a4782864981f50bd5202011579c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:53:01 +0200 Subject: [PATCH 105/707] Bump j178/prek-action from 2.0.4 to 2.0.5 (#175736) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e74f3e694ce3..32909f1dbb31 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -281,7 +281,7 @@ jobs: echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/codespell.json" - name: Run prek - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 + uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 env: PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github @@ -302,7 +302,7 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 + uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 with: extra-args: --all-files zizmor From 830edac54503a5bdfbbc6db4b01ed772ba31bdc3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:58:58 +0200 Subject: [PATCH 106/707] Migrate person entity attributes to StrEnum (#175752) --- homeassistant/components/person/__init__.py | 26 +++++++++++---------- homeassistant/components/person/const.py | 16 +++++++++++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index f2cae74394bf..17153dc3e0b1 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components.device_tracker import ( TrackingType, ) from homeassistant.components.zone import ENTITY_ID_HOME -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_GPS_ACCURACY, ATTR_ID, @@ -53,7 +53,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from .const import DOMAIN +from .const import DOMAIN, PersonEntityStateAttribute _LOGGER = logging.getLogger(__name__) @@ -425,7 +425,9 @@ class Person( ): """Represent a tracked person.""" - _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS}) + _entity_component_unrecorded_attributes = frozenset( + {PersonEntityStateAttribute.DEVICE_TRACKERS} + ) _attr_should_poll = False editable: bool @@ -592,22 +594,22 @@ class Person( def _update_extra_state_attributes(self) -> None: """Update extra state attributes.""" data: dict[str, Any] = { - ATTR_EDITABLE: self.editable, - ATTR_ID: self.unique_id, - ATTR_DEVICE_TRACKERS: self.device_trackers, - ATTR_IN_ZONES: self._in_zones, + PersonEntityStateAttribute.EDITABLE: self.editable, + PersonEntityStateAttribute.ID: self.unique_id, + PersonEntityStateAttribute.DEVICE_TRACKERS: self.device_trackers, + PersonEntityStateAttribute.IN_ZONES: self._in_zones, } if self._latitude is not None: - data[ATTR_LATITUDE] = self._latitude + data[PersonEntityStateAttribute.LATITUDE] = self._latitude if self._longitude is not None: - data[ATTR_LONGITUDE] = self._longitude + data[PersonEntityStateAttribute.LONGITUDE] = self._longitude if self._gps_accuracy is not None: - data[ATTR_GPS_ACCURACY] = self._gps_accuracy + data[PersonEntityStateAttribute.GPS_ACCURACY] = self._gps_accuracy if self._source is not None: - data[ATTR_SOURCE] = self._source + data[PersonEntityStateAttribute.SOURCE] = self._source if (user_id := self._config.get(CONF_USER_ID)) is not None: - data[ATTR_USER_ID] = user_id + data[PersonEntityStateAttribute.USER_ID] = user_id self._attr_extra_state_attributes = data diff --git a/homeassistant/components/person/const.py b/homeassistant/components/person/const.py index dbd228b333ee..f1b283649f2d 100644 --- a/homeassistant/components/person/const.py +++ b/homeassistant/components/person/const.py @@ -1,3 +1,19 @@ """Constants for the person entity platform.""" +from enum import StrEnum + DOMAIN = "person" + + +class PersonEntityStateAttribute(StrEnum): + """State attributes for person entities.""" + + EDITABLE = "editable" + ID = "id" + DEVICE_TRACKERS = "device_trackers" + IN_ZONES = "in_zones" + LATITUDE = "latitude" + LONGITUDE = "longitude" + GPS_ACCURACY = "gps_accuracy" + SOURCE = "source" + USER_ID = "user_id" From 9a1357bb49283eac292b8a2fdbd36c8abcc559cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:59:33 +0200 Subject: [PATCH 107/707] Migrate schedule entity attributes to StrEnum (#175753) --- homeassistant/components/schedule/__init__.py | 17 ++++++++++++----- homeassistant/components/schedule/const.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index b79a22e5ff64..77a4a3aa5050 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -7,7 +7,7 @@ from typing import Any, Literal, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, CONF_ICON, CONF_ID, @@ -40,7 +40,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util import dt as dt_util -from .const import ( +from .const import ( # noqa: F401 ATTR_NEXT_EVENT, CONF_ALL_DAYS, CONF_DATA, @@ -50,6 +50,8 @@ from .const import ( LOGGER, SERVICE_GET, WEEKDAY_TO_CONF, + ScheduleEntityCapabilityAttribute, + ScheduleEntityStateAttribute, ) STORAGE_VERSION = 1 @@ -256,7 +258,10 @@ class Schedule(CollectionEntity): """Schedule entity.""" _entity_component_unrecorded_attributes = frozenset( - {ATTR_EDITABLE, ATTR_NEXT_EVENT} + { + ScheduleEntityCapabilityAttribute.EDITABLE, + ScheduleEntityStateAttribute.NEXT_EVENT, + } ) _attr_has_entity_name = True @@ -269,7 +274,9 @@ class Schedule(CollectionEntity): def __init__(self, config: ConfigType, editable: bool) -> None: """Initialize a schedule.""" self._config = ENTITY_SCHEMA(config) - self._attr_capability_attributes = {ATTR_EDITABLE: editable} + self._attr_capability_attributes = { + ScheduleEntityCapabilityAttribute.EDITABLE: editable + } self._attr_icon = self._config.get(CONF_ICON) self._attr_name = self._config[CONF_NAME] self._attr_unique_id = self._config[CONF_ID] @@ -380,7 +387,7 @@ class Schedule(CollectionEntity): break self._attr_extra_state_attributes = { - ATTR_NEXT_EVENT: next_event, + ScheduleEntityStateAttribute.NEXT_EVENT: next_event, } if current_data: diff --git a/homeassistant/components/schedule/const.py b/homeassistant/components/schedule/const.py index 410cd00c3a08..5bc026e179df 100644 --- a/homeassistant/components/schedule/const.py +++ b/homeassistant/components/schedule/const.py @@ -1,11 +1,25 @@ """Constants for the schedule integration.""" +from enum import StrEnum import logging from typing import Final DOMAIN: Final = "schedule" LOGGER = logging.getLogger(__package__) + +class ScheduleEntityCapabilityAttribute(StrEnum): + """Capability attributes for schedule entities.""" + + EDITABLE = "editable" + + +class ScheduleEntityStateAttribute(StrEnum): + """State attributes for schedule entities.""" + + NEXT_EVENT = "next_event" + + CONF_DATA: Final = "data" CONF_FRIDAY: Final = "friday" CONF_FROM: Final = "from" From 5120e394557a18439ac3d2d3c7367f928534a936 Mon Sep 17 00:00:00 2001 From: Kaew Date: Mon, 6 Jul 2026 18:00:12 +0700 Subject: [PATCH 108/707] Remove Ezviz last alarm pic from sensor, data longer than limit (#169039) --- homeassistant/components/ezviz/__init__.py | 18 ++ homeassistant/components/ezviz/image.py | 33 +- homeassistant/components/ezviz/sensor.py | 5 - homeassistant/components/ezviz/strings.json | 3 - tests/components/ezviz/test_init.py | 339 ++++++++++++++++++++ 5 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 tests/components/ezviz/test_init.py diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 1aa8a2ccb285..dfeaa4d9d58a 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -14,6 +14,7 @@ from pyezvizapi.exceptions import ( from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from .const import ( ATTR_TYPE_CAMERA, @@ -110,6 +111,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry, PLATFORMS_BY_TYPE[sensor_type] ) + # Remove any existing last_alarm_pic sensor entities that were migrated away. + if sensor_type == ATTR_TYPE_CLOUD: + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + for entity_entry in entries: + unique_id = entity_entry.unique_id + if ( + entity_entry.domain == "sensor" + and unique_id is not None + and unique_id.endswith(".last_alarm_pic") + ): + entity_registry.async_remove(entity_entry.entity_id) + _LOGGER.debug( + "Removed legacy last_alarm_pic sensor entity: %s", + entity_entry.entity_id, + ) + return True diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 19e2701477d6..3e29b9c5f683 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -1,5 +1,6 @@ """Support EZVIZ last motion image.""" +from datetime import datetime import logging from typing import override @@ -26,6 +27,13 @@ IMAGE_TYPE = ImageEntityDescription( ) +def _parse_last_alarm_time(last_alarm_time: object) -> datetime | None: + """Parse the last alarm time from the coordinator payload.""" + if not isinstance(last_alarm_time, str): + return None + return dt_util.parse_datetime(last_alarm_time) + + async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, @@ -51,9 +59,12 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): ImageEntity.__init__(self, hass) self._attr_unique_id = f"{serial}_{IMAGE_TYPE.key}" self.entity_description = IMAGE_TYPE - self._attr_image_url = self.data["last_alarm_pic"] - self._attr_image_last_updated = dt_util.parse_datetime( - str(self.data["last_alarm_time"]) + last_alarm_pic = self.data.get("last_alarm_pic") + self._attr_image_url = ( + last_alarm_pic if isinstance(last_alarm_pic, str) else None + ) + self._attr_image_last_updated = _parse_last_alarm_time( + self.data.get("last_alarm_time") ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( @@ -71,6 +82,8 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): @override async def _async_load_image_from_url(self, url: str) -> Image | None: """Load an image by url.""" + if not url: + return None if response := await self._fetch_url(url): image_data = response.content if self.data["encrypted"] and self.alarm_image_password is not None: @@ -96,16 +109,14 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): @override def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if ( - self.data["last_alarm_pic"] - and self.data["last_alarm_pic"] != self._attr_image_url - ): - _LOGGER.debug("Image url changed to %s", self.data["last_alarm_pic"]) + last_alarm_pic = self.data.get("last_alarm_pic") + if last_alarm_pic and last_alarm_pic != self._attr_image_url: + _LOGGER.debug("Image url changed to %s", last_alarm_pic) - self._attr_image_url = self.data["last_alarm_pic"] + self._attr_image_url = last_alarm_pic self._cached_image = None - self._attr_image_last_updated = dt_util.parse_datetime( - str(self.data["last_alarm_time"]) + self._attr_image_last_updated = _parse_last_alarm_time( + self.data.get("last_alarm_time") ) super()._handle_coordinator_update() diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 64b409e4c80f..25db9826faee 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -37,11 +37,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { translation_key="seconds_last_trigger", entity_registry_enabled_default=False, ), - "last_alarm_pic": SensorEntityDescription( - key="last_alarm_pic", - translation_key="last_alarm_pic", - entity_registry_enabled_default=False, - ), "supported_channels": SensorEntityDescription( key="supported_channels", translation_key="supported_channels", diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 818d41c102ce..2cedcd3c78a2 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -111,9 +111,6 @@ "alarm_sound_mod": { "name": "Alarm sound level" }, - "last_alarm_pic": { - "name": "Last alarm picture URL" - }, "last_alarm_time": { "name": "Last alarm time" }, diff --git a/tests/components/ezviz/test_init.py b/tests/components/ezviz/test_init.py new file mode 100644 index 000000000000..d24ce16af627 --- /dev/null +++ b/tests/components/ezviz/test_init.py @@ -0,0 +1,339 @@ +"""Tests for EZVIZ entities.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components import image +from homeassistant.components.ezviz.const import ATTR_TYPE_CLOUD +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + +SCAN_INTERVAL = timedelta(seconds=30) + + +def _mock_camera_data(**kwargs: object) -> dict[str, object]: + """Return a valid mocked EZVIZ camera payload for integration setup.""" + data: dict[str, object] = { + "name": "Camera 1", + "device_sub_category": "CAMERA", + "mac_address": "AA:BB:CC:DD:EE:FF", + "version": "1.0.0", + "status": 1, + "encrypted": False, + "supportExt": {}, + "switches": {}, + "local_ip": "192.168.1.100", + "local_rtsp_port": 554, + "alarm_notify": False, + "upgrade_available": False, + "upgrade_in_progress": False, + "upgrade_percent": 0, + "latest_firmware_info": None, + } + data.update(kwargs) + return data + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain="ezviz", + unique_id="test-username", + title="test-username", + data={ + "session_id": "test-username", + "rf_session_id": "test-password", + "url": "apiieu.ezvizlife.com", + "type": ATTR_TYPE_CLOUD, + }, + ) + + +async def test_image_entity_does_not_expose_last_alarm_pic_attribute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, +) -> None: + """Test that image entity does not include last_alarm_pic as an attribute.""" + # Mock coordinator data with last_alarm_pic + mock_coordinator_data = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + ) + } + + # Mock the load_cameras method to return our test data + mock_ezviz_client.load_cameras.return_value = mock_coordinator_data + + await setup_integration(hass, mock_config_entry) + + # Check that image entity was created without exposing the image URL. + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes + + +async def test_last_alarm_pic_sensor_not_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, +) -> None: + """Test that last_alarm_pic sensor is not created.""" + # Mock coordinator data with all sensor fields + mock_coordinator_data = { + "C123456789": _mock_camera_data( + battery_level=85, + alarm_sound_mod="soft", + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + supported_channels=1, + local_ip="192.168.1.100", + wan_ip="203.0.113.1", + PIR_Status="active", + last_alarm_type_code="motion", + last_alarm_type_name="Motion Detected", + ) + } + + # Mock the load_cameras method to return our test data + mock_ezviz_client.load_cameras.return_value = mock_coordinator_data + + await setup_integration(hass, mock_config_entry) + + # Check that last_alarm_pic sensor was NOT created + last_alarm_pic_state = hass.states.get("sensor.camera_1_last_alarm_pic") + assert last_alarm_pic_state is None + + # But other sensors should be created + registry = er.async_get(hass) + battery_entity = registry.async_get("sensor.camera_1_battery") + assert battery_entity is not None + + +async def test_migrated_last_alarm_pic_sensor_is_removed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrated last_alarm_pic sensor entities are removed on setup.""" + mock_config_entry.add_to_hass(hass) + + migrated_entry = entity_registry.async_get_or_create( + "sensor", + "ezviz", + "C123456789_Camera 1.last_alarm_pic", + config_entry=mock_config_entry, + ) + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + ) + } + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get(migrated_entry.entity_id) is None + + +async def test_sensor_cleanup_ignores_entries_without_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrated sensor cleanup ignores entries with no unique_id.""" + mock_config_entry.add_to_hass(hass) + + entity_without_unique_id = entity_registry.async_get_or_create( + "sensor", + "ezviz", + "C123456789_Camera 1.battery_level", + config_entry=mock_config_entry, + ) + entity_registry.async_update_entity( + entity_without_unique_id.entity_id, + new_unique_id=None, + ) + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + ) + } + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get(entity_without_unique_id.entity_id) is not None + + +async def test_image_entity_created_without_alarm_pic( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, +) -> None: + """Test that image entity is created even when last_alarm_pic is missing.""" + # Mock coordinator data without last_alarm_pic + mock_coordinator_data = { + "C123456789": _mock_camera_data( + battery_level=85, + ) + } + + # Mock the load_cameras method to return our test data + mock_ezviz_client.load_cameras.return_value = mock_coordinator_data + + await setup_integration(hass, mock_config_entry) + + # Check that image entity was created without exposing the image URL. + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes + + +@pytest.mark.parametrize( + ("last_alarm_time", "expected_state"), + [ + pytest.param( + "2023-01-01T12:00:00Z", + "2023-01-01T12:00:00+00:00", + id="valid timestamp", + ), + pytest.param(None, STATE_UNKNOWN, id="missing timestamp"), + pytest.param(123, STATE_UNKNOWN, id="non-string timestamp"), + ], +) +async def test_image_entity_last_alarm_time_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + last_alarm_time: object, + expected_state: str, +) -> None: + """Test image entity state from last_alarm_time data.""" + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time=last_alarm_time, + last_alarm_pic="https://example.com/image.jpg", + ) + } + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert state.state == expected_state + + +async def test_image_entity_updates_last_alarm_pic_on_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the image entity updates its alarm picture on refresh.""" + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image-1.jpg", + ) + } + + await setup_integration(hass, mock_config_entry) + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + return_value=Mock(content=b"image-1"), + ): + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-1" + + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:05:00Z", + last_alarm_pic="https://example.com/image-2.jpg", + ) + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + return_value=Mock(content=b"image-2"), + ): + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-2" + + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes + + +async def test_image_entity_keeps_last_alarm_pic_when_refresh_omits_it( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the image entity keeps the previous alarm picture when omitted.""" + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image-1.jpg", + ) + } + + await setup_integration(hass, mock_config_entry) + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + return_value=Mock(content=b"image-1"), + ): + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-1" + + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:05:00Z", + ) + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + ) as mock_fetch_url: + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-1" + mock_fetch_url.assert_not_called() + + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes From f011a1dc4f7fd47769195bf19f239f6a0431c6f1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 6 Jul 2026 21:01:04 +1000 Subject: [PATCH 109/707] Correct stale repair-issues exempt status in teslemetry quality scale (#175743) --- homeassistant/components/teslemetry/quality_scale.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/quality_scale.yaml b/homeassistant/components/teslemetry/quality_scale.yaml index 941b7ba6779a..f62afec7093b 100644 --- a/homeassistant/components/teslemetry/quality_scale.yaml +++ b/homeassistant/components/teslemetry/quality_scale.yaml @@ -59,9 +59,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: No issues to repair + repair-issues: done stale-devices: done # Platinum async-dependency: done From 333a342187ef691a1221a558fc7d5fe241bd69b6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 13:14:15 +0200 Subject: [PATCH 110/707] Remove leftover _attr_battery_level in mqtt (#175683) --- homeassistant/components/mqtt/vacuum.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index a4c05b8843bb..30604aa4e3a2 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -329,7 +329,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self.add_subscription( CONF_STATE_TOPIC, self._state_message_received, - {"_attr_battery_level", "_attr_fan_speed", "_attr_activity"}, + {"_attr_fan_speed", "_attr_activity"}, ) @override From 91bc39b2840a7c0eddcfc68f50b1b2d89da70f85 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Jul 2026 13:18:34 +0200 Subject: [PATCH 111/707] Move Sure Petcare services to async_setup (#175475) --- .../components/surepetcare/__init__.py | 62 +++----------- .../components/surepetcare/services.py | 85 +++++++++++++++++++ 2 files changed, 97 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/surepetcare/services.py diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 01d21e1be58e..e1d7f163843a 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -3,30 +3,31 @@ from datetime import timedelta import logging -from surepy.enums import Location from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError -import voluptuous as vol -from homeassistant.const import ATTR_LOCATION, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_FLAP_ID, - ATTR_LOCK_STATE, - ATTR_PET_NAME, - DOMAIN, - SERVICE_SET_LOCK_STATE, - SERVICE_SET_PET_LOCATION, -) +from .const import DOMAIN from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=3) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Sure Petcare services.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry) -> bool: """Set up Sure Petcare from a config entry.""" @@ -43,45 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry) entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - lock_state_service_schema = vol.Schema( - { - vol.Required(ATTR_FLAP_ID): vol.All( - cv.positive_int, vol.In(coordinator.data.keys()) - ), - vol.Required(ATTR_LOCK_STATE): vol.All( - cv.string, - vol.Lower, - vol.In(coordinator.lock_states_callbacks.keys()), - ), - } - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_LOCK_STATE, - coordinator.handle_set_lock_state, - schema=lock_state_service_schema, - ) - - set_pet_location_schema = vol.Schema( - { - vol.Required(ATTR_PET_NAME): vol.In(coordinator.get_pets().keys()), - vol.Required(ATTR_LOCATION): vol.In( - [ - Location.INSIDE.name.title(), - Location.OUTSIDE.name.title(), - ] - ), - } - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_PET_LOCATION, - coordinator.handle_set_pet_location, - schema=set_pet_location_schema, - ) - return True diff --git a/homeassistant/components/surepetcare/services.py b/homeassistant/components/surepetcare/services.py new file mode 100644 index 000000000000..74ec28a91aa9 --- /dev/null +++ b/homeassistant/components/surepetcare/services.py @@ -0,0 +1,85 @@ +"""Support for Sure Petcare services.""" + +from surepy.enums import Location +import voluptuous as vol + +from homeassistant.const import ATTR_LOCATION +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, service + +from .const import ( + ATTR_FLAP_ID, + ATTR_LOCK_STATE, + ATTR_PET_NAME, + DOMAIN, + SERVICE_SET_LOCK_STATE, + SERVICE_SET_PET_LOCATION, +) +from .coordinator import SurePetcareConfigEntry + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Sure Petcare services.""" + + async def handle_set_lock_state(call: ServiceCall) -> None: + """Set lock state for a flap.""" + entry: SurePetcareConfigEntry = service.async_get_config_entry( + hass, DOMAIN, None + ) + coordinator = entry.runtime_data + flap_id = call.data[ATTR_FLAP_ID] + if flap_id not in coordinator.data: + raise ServiceValidationError(f"Unknown Sure Petcare flap ID: {flap_id}") + await coordinator.handle_set_lock_state(call) + + async def handle_set_pet_location(call: ServiceCall) -> None: + """Set pet location.""" + entry: SurePetcareConfigEntry = service.async_get_config_entry( + hass, DOMAIN, None + ) + coordinator = entry.runtime_data + pet_name = call.data[ATTR_PET_NAME] + if pet_name not in coordinator.get_pets(): + raise ServiceValidationError(f"Unknown Sure Petcare pet: {pet_name}") + await coordinator.handle_set_pet_location(call) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_LOCK_STATE, + handle_set_lock_state, + schema=vol.Schema( + { + vol.Required(ATTR_FLAP_ID): cv.positive_int, + vol.Required(ATTR_LOCK_STATE): vol.All( + cv.string, + vol.Lower, + vol.In( + [ + "unlocked", + "locked_in", + "locked_out", + "locked_all", + ] + ), + ), + } + ), + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PET_LOCATION, + handle_set_pet_location, + schema=vol.Schema( + { + vol.Required(ATTR_PET_NAME): cv.string, + vol.Required(ATTR_LOCATION): vol.In( + [ + Location.INSIDE.name.title(), + Location.OUTSIDE.name.title(), + ] + ), + } + ), + ) From 724953cdde1246e85e5059e9d09642ffdcbdb4ed Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 6 Jul 2026 21:18:49 +1000 Subject: [PATCH 112/707] Fix teslemetry climate reporting off instead of unknown when coordinator data is missing (#175747) --- .../components/teslemetry/climate.py | 2 +- tests/components/teslemetry/test_climate.py | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 3890e029cd1d..5e3bddb0b872 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -212,7 +212,7 @@ class TeslemetryVehiclePollingClimateEntity( value = self.get("climate_state_is_climate_on") if value is None: self._attr_hvac_mode = None - if value: + elif value: self._attr_hvac_mode = HVACMode.HEAT_COOL else: self._attr_hvac_mode = HVACMode.OFF diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index c7640d991044..68342935a822 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -1,5 +1,6 @@ """Test the Teslemetry climate platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest @@ -19,7 +20,7 @@ from homeassistant.components.climate import ( SERVICE_TURN_ON, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -28,6 +29,7 @@ from . import assert_entities, reload_platform, setup_platform from .const import ( COMMAND_ERRORS, COMMAND_IGNORED_REASON, + METADATA, METADATA_NOSCOPE, VEHICLE_DATA_ALT, ) @@ -202,6 +204,26 @@ async def test_climate_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +async def test_climate_state_unknown( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, +) -> None: + """Test that a missing climate_state_is_climate_on reports unknown, not off.""" + + metadata = deepcopy(METADATA) + metadata["vehicles"]["LRW3F7EK4NC700000"]["polling"] = True + mock_metadata.return_value = metadata + + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["is_climate_on"] = None + mock_vehicle_data.return_value = data + + await setup_platform(hass, [Platform.CLIMATE]) + + assert hass.states.get("climate.test_climate").state == STATE_UNKNOWN + + async def test_invalid_error(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Tests service error is handled.""" From 4036b5b8c3aae12a293b73d30eb54583e8800119 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 13:20:44 +0200 Subject: [PATCH 113/707] Remove previously deprecated battery props from Sharkiq (#175691) --- homeassistant/components/sharkiq/vacuum.py | 9 +-------- tests/components/sharkiq/test_vacuum.py | 5 +---- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index dd82a9e93c49..ac6de4777989 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -65,8 +65,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum _attr_has_entity_name = True _attr_name = None _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -157,12 +156,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum # If the last update was successful... return self.coordinator.last_update_success and self.is_online - @property - @override - def battery_level(self) -> int | None: - """Get the current battery level.""" - return self.sharkiq.get_property_value(Properties.BATTERY_CAPACITY) - @override async def async_return_to_base(self, **kwargs: Any) -> None: """Have the device return to base.""" diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index 1c8d81f93712..397e088832c2 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -26,7 +26,6 @@ from homeassistant.components.sharkiq.vacuum import ( FAN_SPEEDS_MAP, ) from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, SERVICE_LOCATE, @@ -61,8 +60,7 @@ from tests.common import MockConfigEntry VAC_ENTITY_ID = f"vacuum.{SHARK_DEVICE_DICT['product_name'].lower()}" ROOM_LIST = ["Kitchen", "Living Room"] EXPECTED_FEATURES = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -168,7 +166,6 @@ async def test_simple_properties( ("attribute", "target_value"), [ (ATTR_SUPPORTED_FEATURES, EXPECTED_FEATURES), - (ATTR_BATTERY_LEVEL, 50), (ATTR_FAN_SPEED, "Eco"), (ATTR_FAN_SPEED_LIST, list(FAN_SPEEDS_MAP)), (ATTR_ERROR_CODE, 7), From 793da3e37fc3acf465eb2fc61e89fe00ee462229 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 13:21:40 +0200 Subject: [PATCH 114/707] Remove previously deprecated battery prop from LG thinq (#175685) --- homeassistant/components/lg_thinq/vacuum.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index cab183044d38..3f997167dd36 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -65,15 +65,6 @@ ROBOT_STATUS_TO_HA = { "clean_select_gozone": VacuumActivity.CLEANING, "error": VacuumActivity.ERROR, } -ROBOT_BATT_TO_HA = { - "moveless": 5, - "dock_level": 5, - "low": 30, - "mid": 50, - "high": 90, - "full": 100, - "over_charge": 100, -} _LOGGER = logging.getLogger(__name__) @@ -106,7 +97,6 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): _attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.START | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME @@ -120,19 +110,12 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): # Update state. self._attr_activity = ROBOT_STATUS_TO_HA.get(self.data.current_state) - # Update battery. - if (level := self.data.battery) is not None: - self._attr_battery_level = ( - level if isinstance(level, int) else ROBOT_BATT_TO_HA.get(level, 0) - ) - _LOGGER.debug( - "[%s:%s] update status: %s -> %s (battery_level=%s)", + "[%s:%s] update status: %s -> %s", self.coordinator.device_name, self.property_id, self.data.current_state, self.state, - self.battery_level, ) @override From 2b690a3a828aa6b3a4e6706b3f5ecbe43df4e56b Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Jul 2026 13:23:03 +0200 Subject: [PATCH 115/707] Move Xiaomi Miio services to async_setup (#175484) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/xiaomi_miio/const.py | 10 + homeassistant/components/xiaomi_miio/fan.py | 64 +----- homeassistant/components/xiaomi_miio/light.py | 95 +------- .../components/xiaomi_miio/services.py | 213 +++++++++++++++++- .../components/xiaomi_miio/switch.py | 76 +------ 5 files changed, 233 insertions(+), 225 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 67a2d1b77c14..690121850773 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -276,6 +276,16 @@ SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features" SERVICE_SET_DRY = "set_dry" SERVICE_SET_MOTOR_SPEED = "fan_set_motor_speed" +# Fan/Humidifier data +FAN_DATA_KEY = "fan.xiaomi_miio" + +# Light data +LIGHT_DATA_KEY = "light.xiaomi_miio" +ATTR_SCENE = "scene" + +# Switch data +SWITCH_DATA_KEY = "switch.xiaomi_miio" + # Light Services SERVICE_SET_SCENE = "light_set_scene" SERVICE_SET_DELAYED_TURN_OFF = "light_set_delayed_turn_off" diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index d08f52fdf56d..e15d13fa81e7 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,7 +1,6 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" from abc import abstractmethod -import asyncio import logging import math from typing import Any, override @@ -28,12 +27,10 @@ from miio.integrations.fan.dmaker.fan_miot import FanStatusMiot from miio.integrations.fan.zhimi.zhimi_miot import ( OperationModeFanZA5 as FanZA5OperationMode, ) -import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_DEVICE, CONF_MODEL +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.percentage import ( @@ -43,7 +40,7 @@ from homeassistant.util.percentage import ( from .const import ( CONF_FLOW_TYPE, - DOMAIN, + FAN_DATA_KEY as DATA_KEY, FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRFRESH_A1, FEATURE_FLAGS_AIRFRESH_T2017, @@ -89,16 +86,12 @@ from .const import ( MODELS_FAN_MIIO, MODELS_FAN_MIOT, MODELS_PURIFIER_MIOT, - SERVICE_RESET_FILTER, - SERVICE_SET_EXTRA_FEATURES, ) from .entity import XiaomiCoordinatedMiioEntity -from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) -DATA_KEY = "fan.xiaomi_miio" - ATTR_MODE_NATURE = "nature" ATTR_MODE_NORMAL = "normal" @@ -108,7 +101,6 @@ ATTR_FAN_LEVEL = "fan_level" ATTR_SLEEP_TIME = "sleep_time" ATTR_SLEEP_LEARN_COUNT = "sleep_mode_learn_count" ATTR_EXTRA_FEATURES = "extra_features" -ATTR_FEATURES = "features" ATTR_TURBO_MODE_SUPPORTED = "turbo_mode_supported" ATTR_SLEEP_MODE = "sleep_mode" ATTR_USE_TIME = "use_time" @@ -181,20 +173,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [ PRESET_MODES_AIRFRESH = ["Auto", "Interval"] PRESET_MODES_AIRFRESH_A1 = ["Auto", "Sleep", "Favorite"] -AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_FEATURES): cv.positive_int} -) - -SERVICE_TO_METHOD = { - SERVICE_RESET_FILTER: ServiceMethodDetails(method="async_reset_filter"), - SERVICE_SET_EXTRA_FEATURES: ServiceMethodDetails( - method="async_set_extra_features", - schema=SERVICE_SCHEMA_EXTRA_FEATURES, - ), -} - FAN_DIRECTIONS_MAP = { "forward": "right", "reverse": "left", @@ -259,40 +237,6 @@ async def async_setup_entry( entities.append(entity) - async def async_service_handler(service: ServiceCall) -> None: - """Map services to methods on XiaomiAirPurifier.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID - } - if entity_ids := service.data.get(ATTR_ENTITY_ID): - filtered_entities = [ - entity - for entity in hass.data[DATA_KEY].values() - if entity.entity_id in entity_ids - ] - else: - filtered_entities = hass.data[DATA_KEY].values() - - update_tasks = [] - - for entity in filtered_entities: - entity_method = getattr(entity, method.method, None) - if not entity_method: - continue - await entity_method(**params) - update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) - - if update_tasks: - await asyncio.wait(update_tasks) - - for air_purifier_service, method in SERVICE_TO_METHOD.items(): - schema = method.schema or AIRPURIFIER_SERVICE_SCHEMA - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, air_purifier_service, async_service_handler, schema=schema - ) - async_add_entities(entities) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index b3d73141209e..35cf33778e50 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -1,6 +1,5 @@ """Support for Xiaomi Philips Lights.""" -import asyncio import datetime from datetime import timedelta from functools import partial @@ -23,7 +22,6 @@ from miio.gateway.gateway import ( GATEWAY_MODEL_AC_V3, GatewayException, ) -import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -32,44 +30,30 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_DEVICE, - CONF_HOST, - CONF_MODEL, - CONF_TOKEN, -) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util, dt as dt_util from .const import ( + ATTR_SCENE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, + LIGHT_DATA_KEY as DATA_KEY, MODELS_LIGHT_BULB, MODELS_LIGHT_CEILING, MODELS_LIGHT_EYECARE, MODELS_LIGHT_MONO, MODELS_LIGHT_MOON, - SERVICE_EYECARE_MODE_OFF, - SERVICE_EYECARE_MODE_ON, - SERVICE_NIGHT_LIGHT_MODE_OFF, - SERVICE_NIGHT_LIGHT_MODE_ON, - SERVICE_REMINDER_OFF, - SERVICE_REMINDER_ON, - SERVICE_SET_DELAYED_TURN_OFF, - SERVICE_SET_SCENE, ) from .entity import XiaomiGatewayDevice, XiaomiMiioEntity -from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Philips Light" -DATA_KEY = "light.xiaomi_miio" # The light does not accept cct values < 1 CCT_MIN = 1 @@ -79,9 +63,7 @@ DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS = 4 DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES = 1 SUCCESS = ["ok"] -ATTR_SCENE = "scene" ATTR_DELAYED_TURN_OFF = "delayed_turn_off" -ATTR_TIME_PERIOD = "time_period" ATTR_NIGHT_LIGHT_MODE = "night_light_mode" ATTR_AUTOMATIC_COLOR_TEMPERATURE = "automatic_color_temperature" ATTR_REMINDER = "reminder" @@ -94,37 +76,6 @@ ATTR_TOTAL_ASSISTANT_SLEEP_TIME = "total_assistant_sleep_time" ATTR_BAND_SLEEP = "band_sleep" ATTR_BAND = "band" -XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))} -) - -SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period} -) - -SERVICE_TO_METHOD = { - SERVICE_SET_DELAYED_TURN_OFF: ServiceMethodDetails( - method="async_set_delayed_turn_off", - schema=SERVICE_SCHEMA_SET_DELAYED_TURN_OFF, - ), - SERVICE_SET_SCENE: ServiceMethodDetails( - method="async_set_scene", - schema=SERVICE_SCHEMA_SET_SCENE, - ), - SERVICE_REMINDER_ON: ServiceMethodDetails(method="async_reminder_on"), - SERVICE_REMINDER_OFF: ServiceMethodDetails(method="async_reminder_off"), - SERVICE_NIGHT_LIGHT_MODE_ON: ServiceMethodDetails( - method="async_night_light_mode_on" - ), - SERVICE_NIGHT_LIGHT_MODE_OFF: ServiceMethodDetails( - method="async_night_light_mode_off" - ), - SERVICE_EYECARE_MODE_ON: ServiceMethodDetails(method="async_eyecare_mode_on"), - SERVICE_EYECARE_MODE_OFF: ServiceMethodDetails(method="async_eyecare_mode_off"), -} - async def async_setup_entry( hass: HomeAssistant, @@ -212,42 +163,6 @@ async def async_setup_entry( ) return - async def async_service_handler(service: ServiceCall) -> None: - """Map services to methods on Xiaomi Philips Lights.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value - for key, value in service.data.items() - if key != ATTR_ENTITY_ID - } - if entity_ids := service.data.get(ATTR_ENTITY_ID): - target_devices = [ - dev - for dev in hass.data[DATA_KEY].values() - if dev.entity_id in entity_ids - ] - else: - target_devices = hass.data[DATA_KEY].values() - - update_tasks = [] - for target_device in target_devices: - if not hasattr(target_device, method.method): - continue - await getattr(target_device, method.method)(**params) - update_tasks.append( - asyncio.create_task(target_device.async_update_ha_state(True)) - ) - - if update_tasks: - await asyncio.wait(update_tasks) - - for xiaomi_miio_service, method in SERVICE_TO_METHOD.items(): - schema = method.schema or XIAOMI_MIIO_SERVICE_SCHEMA - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema - ) - async_add_entities(entities, update_before_add=True) diff --git a/homeassistant/components/xiaomi_miio/services.py b/homeassistant/components/xiaomi_miio/services.py index 97397f9feecb..7ae0140202e8 100644 --- a/homeassistant/components/xiaomi_miio/services.py +++ b/homeassistant/components/xiaomi_miio/services.py @@ -1,12 +1,39 @@ """Xiaomi services.""" +import asyncio +import logging + import voluptuous as vol from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, service -from .const import DOMAIN +from .const import ( + ATTR_SCENE, + DOMAIN, + FAN_DATA_KEY, + LIGHT_DATA_KEY, + SERVICE_EYECARE_MODE_OFF, + SERVICE_EYECARE_MODE_ON, + SERVICE_NIGHT_LIGHT_MODE_OFF, + SERVICE_NIGHT_LIGHT_MODE_ON, + SERVICE_REMINDER_OFF, + SERVICE_REMINDER_ON, + SERVICE_RESET_FILTER, + SERVICE_SET_DELAYED_TURN_OFF, + SERVICE_SET_EXTRA_FEATURES, + SERVICE_SET_POWER_MODE, + SERVICE_SET_POWER_PRICE, + SERVICE_SET_SCENE, + SERVICE_SET_WIFI_LED_OFF, + SERVICE_SET_WIFI_LED_ON, + SWITCH_DATA_KEY, +) +from .typing import ServiceMethodDetails + +_LOGGER = logging.getLogger(__name__) ATTR_RC_DURATION = "duration" ATTR_RC_ROTATION = "rotation" @@ -23,11 +50,81 @@ SERVICE_CLEAN_SEGMENT = "vacuum_clean_segment" SERVICE_CLEAN_ZONE = "vacuum_clean_zone" SERVICE_GOTO = "vacuum_goto" +# Light Services +ATTR_TIME_PERIOD = "time_period" +XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) +SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))} +) +SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period} +) +LIGHT_SERVICE_TO_METHOD = { + SERVICE_SET_DELAYED_TURN_OFF: ServiceMethodDetails( + method="async_set_delayed_turn_off", + schema=SERVICE_SCHEMA_SET_DELAYED_TURN_OFF, + ), + SERVICE_SET_SCENE: ServiceMethodDetails( + method="async_set_scene", + schema=SERVICE_SCHEMA_SET_SCENE, + ), + SERVICE_REMINDER_ON: ServiceMethodDetails(method="async_reminder_on"), + SERVICE_REMINDER_OFF: ServiceMethodDetails(method="async_reminder_off"), + SERVICE_NIGHT_LIGHT_MODE_ON: ServiceMethodDetails( + method="async_night_light_mode_on" + ), + SERVICE_NIGHT_LIGHT_MODE_OFF: ServiceMethodDetails( + method="async_night_light_mode_off" + ), + SERVICE_EYECARE_MODE_ON: ServiceMethodDetails(method="async_eyecare_mode_on"), + SERVICE_EYECARE_MODE_OFF: ServiceMethodDetails(method="async_eyecare_mode_off"), +} + +# Switch Services +ATTR_PRICE = "price" +SWITCH_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) +SWITCH_SERVICE_SCHEMA_POWER_MODE = SWITCH_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))} +) +SWITCH_SERVICE_SCHEMA_POWER_PRICE = SWITCH_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_PRICE): cv.positive_float} +) +SWITCH_SERVICE_TO_METHOD = { + SERVICE_SET_WIFI_LED_ON: ServiceMethodDetails(method="async_set_wifi_led_on"), + SERVICE_SET_WIFI_LED_OFF: ServiceMethodDetails(method="async_set_wifi_led_off"), + SERVICE_SET_POWER_MODE: ServiceMethodDetails( + method="async_set_power_mode", + schema=SWITCH_SERVICE_SCHEMA_POWER_MODE, + ), + SERVICE_SET_POWER_PRICE: ServiceMethodDetails( + method="async_set_power_price", + schema=SWITCH_SERVICE_SCHEMA_POWER_PRICE, + ), +} + +# Fan Services +ATTR_FEATURES = "features" +FAN_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) +FAN_SERVICE_SCHEMA_EXTRA_FEATURES = FAN_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_FEATURES): cv.positive_int} +) +FAN_SERVICE_TO_METHOD = { + SERVICE_RESET_FILTER: ServiceMethodDetails(method="async_reset_filter"), + SERVICE_SET_EXTRA_FEATURES: ServiceMethodDetails( + method="async_set_extra_features", + schema=FAN_SERVICE_SCHEMA_EXTRA_FEATURES, + ), +} + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up services.""" + _async_setup_fan_services(hass) + _async_setup_light_services(hass) + _async_setup_switch_services(hass) + # Vacuum Services service.async_register_platform_entity_service( hass, @@ -126,3 +223,115 @@ def async_setup_services(hass: HomeAssistant) -> None: schema={vol.Required("segments"): vol.Any(vol.Coerce(int), [vol.Coerce(int)])}, func="async_clean_segment", ) + + +def _async_setup_light_services(hass: HomeAssistant) -> None: + """Set up Xiaomi Miio light services.""" + hass.data.setdefault(LIGHT_DATA_KEY, {}) + + async def async_service_handler(call: ServiceCall) -> None: + """Map services to methods on Xiaomi Philips Lights.""" + method = LIGHT_SERVICE_TO_METHOD[call.service] + params = { + key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID + } + if entity_ids := call.data.get(ATTR_ENTITY_ID): + target_devices = [ + dev + for dev in hass.data[LIGHT_DATA_KEY].values() + if dev.entity_id in entity_ids + ] + else: + target_devices = hass.data[LIGHT_DATA_KEY].values() + + update_tasks = [] + for target_device in target_devices: + if not hasattr(target_device, method.method): + continue + await getattr(target_device, method.method)(**params) + update_tasks.append( + asyncio.create_task(target_device.async_update_ha_state(True)) + ) + + if update_tasks: + await asyncio.wait(update_tasks) + + for xiaomi_miio_service, method in LIGHT_SERVICE_TO_METHOD.items(): + schema = method.schema or XIAOMI_MIIO_SERVICE_SCHEMA + hass.services.async_register( + DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema + ) + + +def _async_setup_switch_services(hass: HomeAssistant) -> None: + """Set up Xiaomi Miio switch services.""" + hass.data.setdefault(SWITCH_DATA_KEY, {}) + + async def async_service_handler(call: ServiceCall) -> None: + """Map services to methods on XiaomiPlugGenericSwitch.""" + method = SWITCH_SERVICE_TO_METHOD[call.service] + params = { + key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID + } + if entity_ids := call.data.get(ATTR_ENTITY_ID): + devices = [ + device + for device in hass.data[SWITCH_DATA_KEY].values() + if device.entity_id in entity_ids + ] + else: + devices = hass.data[SWITCH_DATA_KEY].values() + + update_tasks = [] + for device in devices: + if not hasattr(device, method.method): + continue + await getattr(device, method.method)(**params) + update_tasks.append(asyncio.create_task(device.async_update_ha_state(True))) + + if update_tasks: + await asyncio.wait(update_tasks) + + for plug_service, method in SWITCH_SERVICE_TO_METHOD.items(): + schema = method.schema or SWITCH_SERVICE_SCHEMA + hass.services.async_register( + DOMAIN, plug_service, async_service_handler, schema=schema + ) + + +def _async_setup_fan_services(hass: HomeAssistant) -> None: + """Set up Xiaomi Miio fan services.""" + hass.data.setdefault(FAN_DATA_KEY, {}) + + async def async_service_handler(call: ServiceCall) -> None: + """Map services to methods on XiaomiAirPurifier.""" + method = FAN_SERVICE_TO_METHOD[call.service] + params = { + key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID + } + if entity_ids := call.data.get(ATTR_ENTITY_ID): + filtered_entities = [ + entity + for entity in hass.data[FAN_DATA_KEY].values() + if entity.entity_id in entity_ids + ] + else: + filtered_entities = hass.data[FAN_DATA_KEY].values() + + update_tasks = [] + + for entity in filtered_entities: + entity_method = getattr(entity, method.method, None) + if not entity_method: + continue + await entity_method(**params) + update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) + + if update_tasks: + await asyncio.wait(update_tasks) + + for air_purifier_service, method in FAN_SERVICE_TO_METHOD.items(): + schema = method.schema or FAN_SERVICE_SCHEMA + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, schema=schema + ) diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index f4fc2babc646..f379add73ba0 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -1,6 +1,5 @@ """Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" -import asyncio from dataclasses import dataclass from functools import partial import logging @@ -15,7 +14,6 @@ from miio import ( ) from miio.gateway.devices.switch import Switch from miio.powerstrip import PowerMode -import voluptuous as vol from homeassistant.components.switch import ( SwitchDeviceClass, @@ -23,8 +21,6 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_MODE, ATTR_MODEL, ATTR_TEMPERATURE, CONF_DEVICE, @@ -33,15 +29,13 @@ from homeassistant.const import ( CONF_TOKEN, EntityCategory, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_FLOW_TYPE, CONF_GATEWAY, - DOMAIN, FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRFRESH_A1, FEATURE_FLAGS_AIRFRESH_T2017, @@ -113,20 +107,16 @@ from .const import ( MODELS_HUMIDIFIER_MJJSQ, MODELS_PURIFIER_MIIO, MODELS_PURIFIER_MIOT, - SERVICE_SET_POWER_MODE, - SERVICE_SET_POWER_PRICE, - SERVICE_SET_WIFI_LED_OFF, - SERVICE_SET_WIFI_LED_ON, SUCCESS, + SWITCH_DATA_KEY as DATA_KEY, ) from .coordinator import GatewayDeviceCoordinator from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity -from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Miio Switch" -DATA_KEY = "switch.xiaomi_miio" MODEL_POWER_STRIP_V2 = "zimi.powerstrip.v2" MODEL_PLUG_V3 = "chuangmi.plug.v3" @@ -153,7 +143,6 @@ ATTR_LOAD_POWER = "load_power" ATTR_POWER = "power" ATTR_POWER_MODE = "power_mode" ATTR_POWER_PRICE = "power_price" -ATTR_PRICE = "price" ATTR_PTC = "ptc" ATTR_WIFI_LED = "wifi_led" @@ -171,29 +160,6 @@ FEATURE_FLAGS_POWER_STRIP_V2 = FEATURE_SET_WIFI_LED | FEATURE_SET_POWER_PRICE FEATURE_FLAGS_PLUG_V3 = FEATURE_SET_WIFI_LED -SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -SERVICE_SCHEMA_POWER_MODE = SERVICE_SCHEMA.extend( - {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))} -) - -SERVICE_SCHEMA_POWER_PRICE = SERVICE_SCHEMA.extend( - {vol.Required(ATTR_PRICE): cv.positive_float} -) - -SERVICE_TO_METHOD = { - SERVICE_SET_WIFI_LED_ON: ServiceMethodDetails(method="async_set_wifi_led_on"), - SERVICE_SET_WIFI_LED_OFF: ServiceMethodDetails(method="async_set_wifi_led_off"), - SERVICE_SET_POWER_MODE: ServiceMethodDetails( - method="async_set_power_mode", - schema=SERVICE_SCHEMA_POWER_MODE, - ), - SERVICE_SET_POWER_PRICE: ServiceMethodDetails( - method="async_set_power_price", - schema=SERVICE_SCHEMA_POWER_PRICE, - ), -} - MODEL_TO_FEATURES_MAP = { MODEL_AIRFRESH_A1: FEATURE_FLAGS_AIRFRESH_A1, MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, @@ -486,42 +452,6 @@ async def async_setup_other_entry( model, ) - async def async_service_handler(service: ServiceCall) -> None: - """Map services to methods on XiaomiPlugGenericSwitch.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value - for key, value in service.data.items() - if key != ATTR_ENTITY_ID - } - if entity_ids := service.data.get(ATTR_ENTITY_ID): - devices = [ - device - for device in hass.data[DATA_KEY].values() - if device.entity_id in entity_ids - ] - else: - devices = hass.data[DATA_KEY].values() - - update_tasks = [] - for device in devices: - if not hasattr(device, method.method): - continue - await getattr(device, method.method)(**params) - update_tasks.append( - asyncio.create_task(device.async_update_ha_state(True)) - ) - - if update_tasks: - await asyncio.wait(update_tasks) - - for plug_service, method in SERVICE_TO_METHOD.items(): - schema = method.schema or SERVICE_SCHEMA - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, plug_service, async_service_handler, schema=schema - ) - async_add_entities(entities) From f92893cc492f064ba40690d5e12658df5e0833bf Mon Sep 17 00:00:00 2001 From: Stefan S Date: Mon, 6 Jul 2026 13:55:41 +0200 Subject: [PATCH 116/707] fix typos in KlikAanKlikUit integration (#175720) --- homeassistant/components/klik_aan_klik_uit/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/klik_aan_klik_uit/strings.json b/homeassistant/components/klik_aan_klik_uit/strings.json index 14c367c6cfce..e9648dba6381 100644 --- a/homeassistant/components/klik_aan_klik_uit/strings.json +++ b/homeassistant/components/klik_aan_klik_uit/strings.json @@ -8,7 +8,7 @@ "error": {}, "step": { "pairing_mode": { - "description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.", + "description": "Bring the device into learn mode by pushing its button for more than 2 seconds, then press OK.", "title": "Pair device" }, "pairing_result": { From 439b85a81d8b30552dbb571f7167b7b839133472 Mon Sep 17 00:00:00 2001 From: Linkplay2020 <65423368+Linkplay2020@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:57:16 +0800 Subject: [PATCH 117/707] Add WiiM multi-room grouping support and metadata synchronization (#172537) Co-authored-by: Tao Jiang Co-authored-by: Joost Lekkerkerker --- homeassistant/components/wiim/media_player.py | 114 ++++++++ tests/components/wiim/test_media_player.py | 263 +++++++++++++++++- 2 files changed, 376 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index f7ce8dd9e952..cb392bc228e8 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -31,6 +31,10 @@ from homeassistant.components.media_player import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow @@ -65,9 +69,15 @@ SUPPORT_WIIM_BASE = ( | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.GROUPING ) +def _group_member_state_signal(member_udn: str) -> str: + """Return the dispatcher signal for a grouped member state refresh.""" + return f"wiim_group_member_state_{member_udn}" + + def media_player_exception_wrap[ _WiimMediaPlayerEntityT: WiimMediaPlayerEntity, **_P, @@ -208,6 +218,44 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): ) return target_device + @callback + def _async_handle_group_member_state_refresh(self) -> None: + """Trigger local follower cache refresh on leader update push.""" + group_snapshot = self._get_group_snapshot() + if group_snapshot.role != WiimGroupRole.FOLLOWER: + LOGGER.debug( + "Ignoring group member refresh for %s because role is %s", + self.entity_id, + group_snapshot.role, + ) + return + + LOGGER.debug( + "Follower %s received propagated group update from leader %s", + self.entity_id, + group_snapshot.leader_udn, + ) + self._update_ha_state_from_sdk_cache() + + @callback + def _async_propagate_group_state_update( + self, group_snapshot: WiimGroupSnapshot + ) -> None: + """Notify grouped followers to refresh from the leader's latest cache.""" + if group_snapshot.role != WiimGroupRole.LEADER: + return + + for member_udn in group_snapshot.member_udns: + if member_udn == self._device.udn: + continue + + LOGGER.debug( + "Leader %s propagating grouped state refresh to follower %s", + self.entity_id, + member_udn, + ) + async_dispatcher_send(self.hass, _group_member_state_signal(member_udn)) + @callback def _update_ha_state_from_sdk_cache( self, @@ -290,6 +338,7 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): if write_state: self.async_write_ha_state() + self._async_propagate_group_state_update(group_snapshot) @callback def _handle_sdk_general_device_update(self, device: WiimDevice) -> None: @@ -475,6 +524,13 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): """Run when entity is added to Home Assistant.""" await super().async_added_to_hass() self._wiim_data.entity_id_to_udn_map[self.entity_id] = self._device.udn + self.async_on_remove( + async_dispatcher_connect( + self.hass, + _group_member_state_signal(self._device.udn), + self._async_handle_group_member_state_refresh, + ) + ) LOGGER.debug( "Added %s (UDN: %s) to entity maps in hass.data", self.entity_id, @@ -806,3 +862,61 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): media_content_id, ) raise BrowseError(f"Invalid browse path: {media_content_id}") + + @media_player_exception_wrap + @override + async def async_join_players(self, group_members: list[str]) -> None: + """Join group_members (entity_ids) to the group led by the current player.""" + target_device = self._get_command_target_device("join") + follower_udns_to_join: list[str] = [] + for member_entity_id in group_members: + if member_entity_id == self.entity_id: + LOGGER.debug("Skipping joining self to group: %s", member_entity_id) + continue + + follower_udn = self._wiim_data.entity_id_to_udn_map.get(member_entity_id) + if follower_udn is None: + LOGGER.warning( + "Unable to resolve group member entity_id %s to a UDN", + member_entity_id, + ) + continue + + if follower_udn == target_device.udn: + LOGGER.debug( + "Skipping joining command target to its own group: %s", + member_entity_id, + ) + continue + + follower_udns_to_join.append(follower_udn) + + if not follower_udns_to_join: + LOGGER.debug( + "Skipping join for %s because no follower UDNs were resolved from %s", + self.entity_id, + group_members, + ) + return + + LOGGER.debug( + "Player %s (UDN %s) joining follower UDNs: %s from entity_ids: %s", + self.entity_id, + target_device.udn, + follower_udns_to_join, + group_members, + ) + await self._wiim_data.controller.async_join_group( + target_device.udn, follower_udns_to_join + ) + + @media_player_exception_wrap + @override + async def async_unjoin_player(self) -> None: + """Remove this player from any group it is currently in.""" + LOGGER.debug( + "Player %s (UDN %s) attempting to unjoin from group", + self.entity_id, + self._device.udn, + ) + await self._wiim_data.controller.async_ungroup_device(self._device.udn) diff --git a/tests/components/wiim/test_media_player.py b/tests/components/wiim/test_media_player.py index 057bdc3960a9..ee3ea74aa84b 100644 --- a/tests/components/wiim/test_media_player.py +++ b/tests/components/wiim/test_media_player.py @@ -18,6 +18,7 @@ from wiim.models import ( from wiim.wiim_device import WiimDevice from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_CONTENT_ID, @@ -31,6 +32,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_BROWSE_MEDIA, + SERVICE_JOIN, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, @@ -38,6 +40,7 @@ from homeassistant.components.media_player import ( SERVICE_REPEAT_SET, SERVICE_SELECT_SOURCE, SERVICE_SHUFFLE_SET, + SERVICE_UNJOIN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, BrowseMedia, @@ -47,7 +50,9 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.components.wiim as wiim_component +from homeassistant.components.wiim.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST from homeassistant.core import HomeAssistant from . import fire_general_update, fire_transport_update, setup_integration @@ -57,6 +62,52 @@ from tests.common import MockConfigEntry MEDIA_PLAYER_ENTITY_ID = "media_player.test_wiim_device" +def _build_mock_wiim_device( + *, + udn: str, + name: str, + ip_address: str, + base_device: MagicMock, +) -> AsyncMock: + """Build a mocked WiiM device for a second integration entry.""" + device = AsyncMock(spec=WiimDevice) + device.udn = udn + device.name = name + device.model_name = "WiiM Pro" + device.manufacturer = "Linkplay Tech" + device.firmware_version = "4.8.523456" + device.ip_address = ip_address + device.http_api_url = f"http://{ip_address}:8080" + device.presentation_url = f"http://{ip_address}:8080/web_interface" + device.available = True + device.volume = 40 + device.is_muted = False + device.supports_http_api = False + device.playing_status = PlayingStatus.STOPPED + device.play_mode = "Network" + device.loop_state = WiimLoopState( + repeat=WiimRepeatMode.OFF, + shuffle=False, + ) + device.output_mode = "speaker" + device.current_media = None + device.supported_input_modes = base_device.supported_input_modes + device.supported_output_modes = base_device.supported_output_modes + device.async_get_transport_capabilities = AsyncMock( + return_value=WiimTransportCapabilities( + can_next=False, + can_previous=False, + can_repeat=False, + can_shuffle=False, + ) + ) + device.general_event_callback = None + device.av_transport_event_callback = None + device.rendering_control_event_callback = None + device.play_queue_event_callback = None + return device + + async def test_state_machine_updates_from_device_callbacks( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -79,6 +130,7 @@ async def test_state_machine_updates_from_device_callbacks( | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.GROUPING ) mock_wiim_device.volume = 60 @@ -131,6 +183,7 @@ async def test_state_machine_updates_from_device_callbacks( | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.GROUPING ) @@ -429,6 +482,87 @@ async def test_follower_routes_commands_and_reads_leader_metadata( assert state.attributes[ATTR_MEDIA_POSITION] == 90 +async def test_group_refresh_dispatcher_sends_to_followers_and_refreshes_member( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + mock_wiim_controller: MagicMock, +) -> None: + """Test leader group refresh signal makes a real follower entity refresh.""" + follower_device = _build_mock_wiim_device( + udn="uuid:follower-1234", + name="Follower WiiM Device", + ip_address="192.168.1.101", + base_device=mock_wiim_device, + ) + + wiim_component.async_create_wiim_device.side_effect = [ + mock_wiim_device, + follower_device, + ] + follower_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.101"}, + title="Follower WiiM Device", + unique_id=follower_device.udn, + ) + + await setup_integration(hass, mock_config_entry) + await setup_integration(hass, follower_config_entry) + + def group_snapshot_for(udn: str) -> WiimGroupSnapshot: + if udn == mock_wiim_device.udn: + return WiimGroupSnapshot( + role=WiimGroupRole.LEADER, + leader_udn=mock_wiim_device.udn, + member_udns=(mock_wiim_device.udn, follower_device.udn), + ) + return WiimGroupSnapshot( + role=WiimGroupRole.FOLLOWER, + leader_udn=mock_wiim_device.udn, + member_udns=(mock_wiim_device.udn, follower_device.udn), + ) + + mock_wiim_controller.get_group_snapshot.side_effect = group_snapshot_for + mock_wiim_controller.get_device.side_effect = lambda udn: ( + mock_wiim_device if udn == mock_wiim_device.udn else follower_device + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: ["media_player.follower_wiim_device"], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_awaited_once_with( + mock_wiim_device.udn, + [follower_device.udn], + ) + + mock_wiim_device.playing_status = PlayingStatus.PLAYING + mock_wiim_device.play_mode = "Spotify" + mock_wiim_device.current_media = WiimMediaMetadata( + title="Leader Signal Song", + album="Leader Signal Album", + duration=240, + position=33, + ) + + await fire_general_update(hass, mock_wiim_device) + + follower_state = hass.states.get("media_player.follower_wiim_device") + assert follower_state is not None + assert follower_state.state == MediaPlayerState.PLAYING + assert follower_state.attributes[ATTR_MEDIA_TITLE] == "Leader Signal Song" + assert follower_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Leader Signal Album" + assert follower_state.attributes[ATTR_INPUT_SOURCE] == "Spotify" + assert follower_state.attributes[ATTR_MEDIA_POSITION] == 33 + + async def test_follower_routes_repeat_shuffle_and_source_commands_to_leader( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -717,3 +851,130 @@ async def test_browse_media_service_includes_media_sources_when_supported( "Queue", "song.mp3", ] + + +async def test_join_and_unjoin_services_use_resolved_member_udns( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + mock_wiim_controller: MagicMock, +) -> None: + """Test grouping services call the controller with resolved UDNs.""" + follower_device = _build_mock_wiim_device( + udn="uuid:follower-1234", + name="Follower WiiM Device", + ip_address="192.168.1.101", + base_device=mock_wiim_device, + ) + second_follower_device = _build_mock_wiim_device( + udn="uuid:follower-5678", + name="Second Follower WiiM Device", + ip_address="192.168.1.102", + base_device=mock_wiim_device, + ) + wiim_component.async_create_wiim_device.side_effect = [ + mock_wiim_device, + follower_device, + second_follower_device, + ] + follower_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.101"}, + title=follower_device.name, + unique_id=follower_device.udn, + ) + second_follower_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.102"}, + title=second_follower_device.name, + unique_id=second_follower_device.udn, + ) + + await setup_integration(hass, mock_config_entry) + await setup_integration(hass, follower_config_entry) + await setup_integration(hass, second_follower_config_entry) + + follower_entity_id = "media_player.follower_wiim_device" + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: [ + MEDIA_PLAYER_ENTITY_ID, + follower_entity_id, + "media_player.unknown_wiim_device", + ], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_awaited_once_with( + mock_wiim_device.udn, [follower_device.udn] + ) + mock_wiim_controller.async_join_group.reset_mock() + + leader_device = AsyncMock(spec=WiimDevice) + leader_device.udn = "uuid:leader-1234" + leader_device.name = "Leader WiiM Device" + leader_device.playing_status = PlayingStatus.STOPPED + leader_device.play_mode = "Network" + leader_device.loop_state = WiimLoopState( + repeat=WiimRepeatMode.OFF, + shuffle=False, + ) + leader_device.current_media = None + second_follower_entity_id = "media_player.second_follower_wiim_device" + mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot( + role=WiimGroupRole.FOLLOWER, + leader_udn=leader_device.udn, + member_udns=(leader_device.udn, mock_wiim_device.udn), + ) + mock_wiim_controller.get_device.side_effect = lambda udn: ( + leader_device if udn == leader_device.udn else mock_wiim_device + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: [ + MEDIA_PLAYER_ENTITY_ID, + second_follower_entity_id, + ], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_awaited_once_with( + leader_device.udn, [second_follower_device.udn] + ) + mock_wiim_controller.async_join_group.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: [ + MEDIA_PLAYER_ENTITY_ID, + "media_player.unknown_wiim_device", + ], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_not_awaited() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_wiim_controller.async_ungroup_device.assert_awaited_once_with( + mock_wiim_device.udn + ) From 229e009dfd9a3d92617e7f1010a69ed86b47ea2d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:58:01 -0400 Subject: [PATCH 118/707] Add command aliases to Vizio remote platform (#166839) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/vizio/remote.py | 31 ++++++++++++++++++++++++ tests/components/vizio/test_remote.py | 22 ++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py index 3b44dffbd3a4..d5b86fc2c882 100644 --- a/homeassistant/components/vizio/remote.py +++ b/homeassistant/components/vizio/remote.py @@ -23,6 +23,32 @@ from .coordinator import VizioConfigEntry, VizioDeviceCoordinator PARALLEL_UPDATES = 0 +# Maps native pyvizio key names to human-friendly aliases. +# Keys are uppercase native names (e.g. "CC_TOGGLE"), values are lists of lowercase aliases. +REMOTE_KEY_ALIASES: dict[str, list[str]] = { + "CC_TOGGLE": ["closed_captions", "cc"], + "CH_DOWN": ["channel_down"], + "CH_PREV": ["previous_channel"], + "CH_UP": ["channel_up"], + "INPUT_NEXT": ["next_input"], + "MUTE_TOGGLE": ["mute", "toggle_mute"], + "OK": ["enter", "select"], + "PIC_MODE": ["picture_mode"], + "PIC_SIZE": ["picture_size"], + "POW_OFF": ["off", "power_off"], + "POW_ON": ["on", "power_on"], + "POW_TOGGLE": ["power", "power_toggle", "toggle_power"], + "SEEK_BACK": ["reverse", "rewind"], + "SEEK_FWD": ["forward", "fast_forward", "ff"], + "VOL_DOWN": ["volume_down"], + "VOL_UP": ["volume_up"], +} + +# Invert aliases into {alias: native_key} for O(1) lookup +_ALIAS_LOOKUP: dict[str, str] = { + alias: key for key, aliases in REMOTE_KEY_ALIASES.items() for alias in aliases +} + async def async_setup_entry( hass: HomeAssistant, @@ -49,7 +75,12 @@ class VizioRemote(CoordinatorEntity[VizioDeviceCoordinator], RemoteEntity): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) self._device = coordinator.device valid_keys = set(self._device.get_remote_keys_list()) + # Map lowercased native keys to their original uppercase pyvizio names self._command_map: dict[str, str] = {key.lower(): key for key in valid_keys} + # Add aliases only for native keys this device actually supports + for alias, target in _ALIAS_LOOKUP.items(): + if target in valid_keys: + self._command_map[alias] = target @property @override diff --git a/tests/components/vizio/test_remote.py b/tests/components/vizio/test_remote.py index 3c7e3e410265..d3fe4a900c7c 100644 --- a/tests/components/vizio/test_remote.py +++ b/tests/components/vizio/test_remote.py @@ -110,6 +110,12 @@ async def test_turn_on_off( ("BACK", "BACK"), ("ch_up", "CH_UP"), ("SMARTCAST", "SMARTCAST"), + # Aliases + ("closed_captions", "CC_TOGGLE"), + ("channel_up", "CH_UP"), + ("enter", "OK"), + ("volume_down", "VOL_DOWN"), + ("VoLuMe_DoWn", "VOL_DOWN"), ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") @@ -160,9 +166,13 @@ async def test_send_command_tv_invalid( @pytest.mark.parametrize( ("command", "expected_key"), [ + # Native keys (one lowercase variant tested) ("MUTE_TOGGLE", "MUTE_TOGGLE"), ("pause", "PAUSE"), ("VOL_UP", "VOL_UP"), + # Aliases (only those whose target is a speaker key) + ("mute", "MUTE_TOGGLE"), + ("volume_down", "VOL_DOWN"), ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") @@ -189,7 +199,17 @@ async def test_send_command_speaker_valid( mock_remote.assert_called_once_with(expected_key, log_api_exception=False) -@pytest.mark.parametrize("command", ["MENU", "CH_UP", "INVALID_KEY"]) +@pytest.mark.parametrize( + "command", + [ + "MENU", + "CH_UP", + # TV-only alias + "channel_up", + # Completely invalid + "INVALID_KEY", + ], +) @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_send_command_speaker_invalid( hass: HomeAssistant, From e0576ac67543de0ce48c8140af58bf4f94cca22e Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Mon, 6 Jul 2026 14:38:20 +0200 Subject: [PATCH 119/707] Get stiebel_eltron integration to bronze level (#174953) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../components/stiebel_eltron/coordinator.py | 11 --- .../components/stiebel_eltron/manifest.json | 1 + .../stiebel_eltron/quality_scale.yaml | 76 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 4 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/stiebel_eltron/quality_scale.yaml diff --git a/homeassistant/components/stiebel_eltron/coordinator.py b/homeassistant/components/stiebel_eltron/coordinator.py index 2b278fb0169c..af5bf3cc2a72 100644 --- a/homeassistant/components/stiebel_eltron/coordinator.py +++ b/homeassistant/components/stiebel_eltron/coordinator.py @@ -42,7 +42,6 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]): # the register values), so there is nothing to diff against. always_update=True, ) - self._model = model self.api_client = LwzStiebelEltronAPI(host=host, port=port) self.device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -58,21 +57,11 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]): _LOGGER.debug("Closing connection to %s", self.host) await self.api_client.close() - @property - def is_connected(self) -> bool: - """Check modbus client connection status.""" - return self.api_client.is_connected - @property def host(self) -> str: """Return the host address of the Stiebel Eltron ISG.""" return self.api_client.host - @property - def model(self) -> str: - """Return the controller model name of the Stiebel Eltron ISG.""" - return self._model.name - @override async def _async_update_data(self) -> None: """Fetch the latest data from the source.""" diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index f3ff88e0e2b7..ab19f70dc13f 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -7,5 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], + "quality_scale": "bronze", "requirements": ["pystiebeleltron==0.2.5"] } diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml new file mode 100644 index 000000000000..d476c182a829 --- /dev/null +++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: + status: exempt + comment: Integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not require reauthentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Integration does not use web sessions. + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4a46a5ca06a3..5add8bb55ee0 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -862,7 +862,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "statsd", "steam_online", "steamist", - "stiebel_eltron", "stream", "streamlabswater", "subaru", @@ -1827,7 +1826,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "statsd", "steam_online", "steamist", - "stiebel_eltron", "stream", "streamlabswater", "stookwijzer", From 2bcfca08469eaf27ff11c004aeb86f1dfa21f175 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:51:56 +0200 Subject: [PATCH 120/707] Use Attribute enum in humidifier (#175358) --- .../components/humidifier/condition.py | 29 ++++++------ .../components/humidifier/device_action.py | 7 ++- .../components/humidifier/device_condition.py | 17 +++++-- .../components/humidifier/device_trigger.py | 8 +++- homeassistant/components/humidifier/intent.py | 7 ++- .../components/humidifier/reproduce_state.py | 46 +++++++++++++------ .../humidifier/significant_change.py | 15 +++--- .../components/humidifier/trigger.py | 19 ++++++-- 8 files changed, 100 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index ac9e37270a72..55ddc6eb3044 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -4,14 +4,7 @@ from typing import TYPE_CHECKING, override import voluptuous as vol -from homeassistant.const import ( - ATTR_MODE, - CONF_MODE, - CONF_OPTIONS, - PERCENTAGE, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import CONF_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -27,11 +20,10 @@ from homeassistant.helpers.condition import ( from homeassistant.helpers.entity import get_supported_features from .const import ( - ATTR_ACTION, - ATTR_HUMIDITY, DOMAIN, HumidifierAction, HumidifierEntityFeature, + HumidifierEntityStateAttribute, ) IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( @@ -54,7 +46,9 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo class IsTargetHumidityCondition(EntityNumericalConditionBase): """Condition for humidifier target humidity.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.HUMIDITY) + } _valid_unit = PERCENTAGE @override @@ -62,14 +56,17 @@ class IsTargetHumidityCondition(EntityNumericalConditionBase): """Skip humidifier entities that do not expose a target humidity.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_HUMIDITY) is not None + and state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY) + is not None ) class IsModeCondition(EntityStateConditionBase): """Condition for humidifier mode.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.MODE) + } _schema = IS_MODE_CONDITION_SCHEMA def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: @@ -94,10 +91,12 @@ CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), "is_on": make_entity_state_condition(DOMAIN, STATE_ON), "is_drying": make_entity_state_condition( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.DRYING, ), "is_humidifying": make_entity_state_condition( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.HUMIDIFYING, ), "is_mode": IsModeCondition, "is_target_humidity": IsTargetHumidityCondition, diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index d3ae95cfc5d0..3e2124e129a3 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -122,7 +122,12 @@ async def async_get_action_capabilities( hass, config[CONF_ENTITY_ID] ) available_modes = ( - get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.HumidifierEntityCapabilityAttribute.AVAILABLE_MODES, + ) + or [] ) except HomeAssistantError: available_modes = [] diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index f3ac0c116058..193f3f54d3ca 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -42,6 +42,11 @@ MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( CONDITION_SCHEMA = vol.Any(TOGGLE_CONDITION, MODE_CONDITION) +# Maps a state attribute to the condition config key used to compare against it. +_STATE_ATTRIBUTE_TO_CONFIG_KEY = { + const.HumidifierEntityStateAttribute.MODE: ATTR_MODE, +} + async def async_get_conditions( hass: HomeAssistant, device_id: str @@ -77,7 +82,7 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" if config[CONF_TYPE] == "is_mode": - attribute = ATTR_MODE + attribute = const.HumidifierEntityStateAttribute.MODE else: return toggle_entity.async_condition_from_config(hass, config) @@ -89,7 +94,8 @@ def async_condition_from_config( return ( entity_id is not None and (state := hass.states.get(entity_id)) is not None - and state.attributes.get(attribute) == config[attribute] + and state.attributes.get(attribute) + == config[_STATE_ATTRIBUTE_TO_CONFIG_KEY[attribute]] ) return test_is_state @@ -109,7 +115,12 @@ async def async_get_condition_capabilities( hass, config[CONF_ENTITY_ID] ) modes = ( - get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.HumidifierEntityCapabilityAttribute.AVAILABLE_MODES, + ) + or [] ) except HomeAssistantError: modes = [] diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 7fe5adac40fb..bfd2fe00844a 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -25,7 +25,8 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import ATTR_CURRENT_HUMIDITY, DOMAIN +from . import DOMAIN +from .const import HumidifierEntityStateAttribute # mypy: disallow-any-generics @@ -94,7 +95,10 @@ async def async_get_triggers( } ) - if state and ATTR_CURRENT_HUMIDITY in state.attributes: + if ( + state + and HumidifierEntityStateAttribute.CURRENT_HUMIDITY in state.attributes + ): triggers.append( { **base_trigger, diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 48bafdb1113c..70ed62260155 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent from . import ( - ATTR_AVAILABLE_MODES, ATTR_HUMIDITY, DOMAIN, SERVICE_SET_HUMIDITY, @@ -17,6 +16,7 @@ from . import ( SERVICE_TURN_ON, HumidifierEntityFeature, ) +from .const import HumidifierEntityCapabilityAttribute INTENT_HUMIDITY = "HassHumidifierSetpoint" INTENT_MODE = "HassHumidifierMode" @@ -117,7 +117,10 @@ class SetModeHandler(intent.IntentHandler): intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes") mode = slots["mode"]["value"] - if mode not in (state.attributes.get(ATTR_AVAILABLE_MODES) or []): + if mode not in ( + state.attributes.get(HumidifierEntityCapabilityAttribute.AVAILABLE_MODES) + or [] + ): raise intent.IntentHandleError( f"Entity {state.name} does not support {mode} mode" ) diff --git a/homeassistant/components/humidifier/reproduce_state.py b/homeassistant/components/humidifier/reproduce_state.py index 45941f327374..f4eb37d33a1b 100644 --- a/homeassistant/components/humidifier/reproduce_state.py +++ b/homeassistant/components/humidifier/reproduce_state.py @@ -14,10 +14,22 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, State -from .const import ATTR_HUMIDITY, DOMAIN, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE +from .const import ( + ATTR_HUMIDITY, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + HumidifierEntityStateAttribute, +) _LOGGER = logging.getLogger(__name__) +# Maps a state attribute to the service call argument used to restore it. +_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[HumidifierEntityStateAttribute, str] = { + HumidifierEntityStateAttribute.MODE: ATTR_MODE, + HumidifierEntityStateAttribute.HUMIDITY: ATTR_HUMIDITY, +} + async def _async_reproduce_states( hass: HomeAssistant, @@ -31,12 +43,16 @@ async def _async_reproduce_states( _LOGGER.warning("Unable to find entity %s", state.entity_id) return - async def call_service(service: str, keys: Iterable[str]) -> None: - """Call service with set of attributes given.""" + async def call_service( + service: str, attributes: Iterable[HumidifierEntityStateAttribute] + ) -> None: + """Call service with the given state attributes.""" data = {"entity_id": state.entity_id} - for key in keys: - if key in state.attributes: - data[key] = state.attributes[key] + for attribute in attributes: + if attribute in state.attributes: + data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = state.attributes[ + attribute + ] await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context @@ -66,16 +82,18 @@ async def _async_reproduce_states( # Then set the mode before target humidity, because switching modes # may invalidate target humidity - if ATTR_MODE in state.attributes and state.attributes[ - ATTR_MODE - ] != cur_state.attributes.get(ATTR_MODE): - await call_service(SERVICE_SET_MODE, [ATTR_MODE]) + if HumidifierEntityStateAttribute.MODE in state.attributes and state.attributes[ + HumidifierEntityStateAttribute.MODE + ] != cur_state.attributes.get(HumidifierEntityStateAttribute.MODE): + await call_service(SERVICE_SET_MODE, [HumidifierEntityStateAttribute.MODE]) # Next, restore target humidity for the current mode - if ATTR_HUMIDITY in state.attributes and state.attributes[ - ATTR_HUMIDITY - ] != cur_state.attributes.get(ATTR_HUMIDITY): - await call_service(SERVICE_SET_HUMIDITY, [ATTR_HUMIDITY]) + if HumidifierEntityStateAttribute.HUMIDITY in state.attributes and state.attributes[ + HumidifierEntityStateAttribute.HUMIDITY + ] != cur_state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY): + await call_service( + SERVICE_SET_HUMIDITY, [HumidifierEntityStateAttribute.HUMIDITY] + ) async def async_reproduce_states( diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py index c8d588512a04..ff61fc6962e8 100644 --- a/homeassistant/components/humidifier/significant_change.py +++ b/homeassistant/components/humidifier/significant_change.py @@ -8,13 +8,13 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE +from .const import HumidifierEntityStateAttribute SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_ACTION, - ATTR_CURRENT_HUMIDITY, - ATTR_HUMIDITY, - ATTR_MODE, + HumidifierEntityStateAttribute.ACTION, + HumidifierEntityStateAttribute.CURRENT_HUMIDITY, + HumidifierEntityStateAttribute.HUMIDITY, + HumidifierEntityStateAttribute.MODE, } @@ -40,7 +40,10 @@ def async_check_significant_change( changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name in [ATTR_ACTION, ATTR_MODE]: + if attr_name in [ + HumidifierEntityStateAttribute.ACTION, + HumidifierEntityStateAttribute.MODE, + ]: return True old_attr_value = old_attrs.get(attr_name) diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py index 23c4e5476b25..3b01529de6b5 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -4,7 +4,7 @@ from typing import override import voluptuous as vol -from homeassistant.const import ATTR_MODE, CONF_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -18,7 +18,12 @@ from homeassistant.helpers.trigger import ( make_entity_target_state_trigger, ) -from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature +from .const import ( + DOMAIN, + HumidifierAction, + HumidifierEntityFeature, + HumidifierEntityStateAttribute, +) MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend( { @@ -40,7 +45,9 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo class ModeChangedTrigger(EntityTargetStateTriggerBase): """Trigger for humidifier mode changes.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.MODE) + } _schema = MODE_CHANGED_TRIGGER_SCHEMA def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: @@ -62,10 +69,12 @@ class ModeChangedTrigger(EntityTargetStateTriggerBase): TRIGGERS: dict[str, type[Trigger]] = { "mode_changed": ModeChangedTrigger, "started_drying": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.DRYING, ), "started_humidifying": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.HUMIDIFYING, ), "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), From c6eef394c88d72540b9777e0c02bfacaa4406b5c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 6 Jul 2026 22:57:18 +1000 Subject: [PATCH 121/707] Enable SSL by default for Splunk (#175729) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Josef Zweck Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/splunk/config_flow.py | 8 ++-- homeassistant/components/splunk/const.py | 2 +- tests/components/splunk/test_config_flow.py | 37 ++++++++++++++++++- tests/components/splunk/test_init.py | 21 +++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/splunk/config_flow.py b/homeassistant/components/splunk/config_flow.py index 43157661be74..437b8ad24e6e 100644 --- a/homeassistant/components/splunk/config_flow.py +++ b/homeassistant/components/splunk/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SSL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -56,7 +56,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_TOKEN): str, vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_VERIFY_SSL, default=True): bool, vol.Optional(CONF_NAME): str, } @@ -109,7 +109,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_TOKEN): str, vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_VERIFY_SSL, default=True): bool, vol.Optional(CONF_NAME): str, } @@ -159,7 +159,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN): host=user_input.get(CONF_HOST, DEFAULT_HOST), port=user_input.get(CONF_PORT, DEFAULT_PORT), token=user_input[CONF_TOKEN], - use_ssl=user_input.get(CONF_SSL, False), + use_ssl=user_input.get(CONF_SSL, DEFAULT_SSL), verify_ssl=user_input.get(CONF_VERIFY_SSL, True), ) diff --git a/homeassistant/components/splunk/const.py b/homeassistant/components/splunk/const.py index 1be3beaf5019..c9cec957ca8e 100644 --- a/homeassistant/components/splunk/const.py +++ b/homeassistant/components/splunk/const.py @@ -6,5 +6,5 @@ CONF_FILTER = "filter" DEFAULT_HOST = "localhost" DEFAULT_PORT = 8088 -DEFAULT_SSL = False +DEFAULT_SSL = True DEFAULT_NAME = "HASS" diff --git a/tests/components/splunk/test_config_flow.py b/tests/components/splunk/test_config_flow.py index dd5d33dd0cf0..d8b55f14afd9 100644 --- a/tests/components/splunk/test_config_flow.py +++ b/tests/components/splunk/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -59,6 +59,27 @@ async def test_user_flow_success( assert mock_hass_splunk.check.call_count == 2 +@pytest.mark.usefixtures("mock_hass_splunk") +async def test_user_flow_defaults_ssl_on(hass: HomeAssistant) -> None: + """Test a new entry defaults to SSL enabled when the field is omitted.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "test-token-123", + CONF_HOST: "splunk.example.com", + CONF_PORT: 8088, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SSL] is True + assert result["data"][CONF_VERIFY_SSL] is True + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -258,6 +279,20 @@ async def test_reconfigure_flow_success( assert mock_config_entry.title == "new-splunk.example.com:9088" +@pytest.mark.usefixtures("mock_hass_splunk") +async def test_reconfigure_flow_preserves_stored_ssl( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure pre-fills the stored SSL value, not the schema default.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert get_schema_suggested_value(result["data_schema"].schema, CONF_SSL) is False + + @pytest.mark.parametrize( ("side_effect", "error"), [ diff --git a/tests/components/splunk/test_init.py b/tests/components/splunk/test_init.py index 91148bcda291..f6fde25ab9a9 100644 --- a/tests/components/splunk/test_init.py +++ b/tests/components/splunk/test_init.py @@ -120,6 +120,27 @@ async def test_yaml_import_without_filter( assert entries[0].source == SOURCE_IMPORT +@pytest.mark.usefixtures("mock_setup_entry", "mock_hass_splunk") +async def test_yaml_import_defaults_ssl_on(hass: HomeAssistant) -> None: + """Test YAML import defaults to SSL enabled when the field is omitted.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_TOKEN: "test-token", + CONF_HOST: "localhost", + CONF_PORT: 8088, + } + }, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data[CONF_SSL] is True + + @pytest.mark.usefixtures("mock_setup_entry") async def test_yaml_with_filter( hass: HomeAssistant, mock_hass_splunk: AsyncMock From 7678cf98caafbf6881a4abf3992dff2114a97912 Mon Sep 17 00:00:00 2001 From: mattreim <80219712+mattreim@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:17:35 +0200 Subject: [PATCH 122/707] Fix swallowed exceptions in deconz actions (#175646) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ariel Ebersberger --- homeassistant/components/deconz/services.py | 85 +++++++++++------ homeassistant/components/deconz/strings.json | 17 ++++ tests/components/deconz/conftest.py | 30 ++++-- tests/components/deconz/test_services.py | 98 ++++++++++++++++++-- 4 files changed, 182 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index bff21a0c69cb..c06112f820a2 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,11 +1,11 @@ """deCONZ services.""" -from typing import TYPE_CHECKING - +from pydeconz import errors from pydeconz.utils import normalize_bridge_id import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -14,14 +14,10 @@ from homeassistant.helpers import ( from homeassistant.helpers.service import async_register_admin_service from homeassistant.util.read_only_dict import ReadOnlyDict -from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER +from .const import CONF_BRIDGE_ID, DOMAIN from .hub import DeconzHub from .util import get_master_hub -if TYPE_CHECKING: - from . import DeconzConfigEntry - - DECONZ_SERVICES = "deconz_services" SERVICE_FIELD = "field" @@ -68,27 +64,34 @@ def async_setup_services(hass: HomeAssistant) -> None: service_data = service_call.data if CONF_BRIDGE_ID in service_data: - found_hub = False bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID]) - entry: DeconzConfigEntry - for entry in hass.config_entries.async_loaded_entries(DOMAIN): - possible_hub = entry.runtime_data - if possible_hub.bridgeid == bridge_id: - hub = possible_hub - found_hub = True - break + hub: DeconzHub | None = next( + ( + entry.runtime_data + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.runtime_data.bridgeid == bridge_id + ), + None, + ) + + if hub is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="gateway_not_found", + translation_placeholders={ + "bridge_id": bridge_id, + }, + ) - if not found_hub: - LOGGER.error("Could not find the gateway %s", bridge_id) - return else: try: hub = get_master_hub(hass) - # pylint: disable-next=home-assistant-action-swallowed-exception - except ValueError: - LOGGER.error("No master gateway available") - return + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_master_gateway", + ) from err if service == SERVICE_CONFIGURE_DEVICE: await async_configure_service(hub, service_data) @@ -132,19 +135,38 @@ async def async_configure_service(hub: DeconzHub, data: ReadOnlyDict) -> None: if entity_id: try: field = hub.deconz_ids[entity_id] + field - except KeyError: - LOGGER.error("Could not find the entity %s", entity_id) - return + except KeyError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={ + "entity_id": entity_id, + }, + ) from err - await hub.api.request("put", field, json=data) + try: + await hub.api.request("put", field, json=data) + except (TimeoutError, errors.pydeconzException) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="configure_failed", + ) from err async def async_refresh_devices_service(hub: DeconzHub) -> None: """Refresh available devices from deCONZ.""" hub.ignore_state_updates = True - await hub.api.refresh_state() - hub.load_ignored_devices() - hub.ignore_state_updates = False + + try: + await hub.api.refresh_state() + hub.load_ignored_devices() + except (TimeoutError, errors.pydeconzException) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_refresh_failed", + ) from err + finally: + hub.ignore_state_updates = False async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: @@ -183,6 +205,7 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: if entry.device_id in devices_to_be_removed: devices_to_be_removed.remove(entry.device_id) continue + # Remove entities that are not available entities_to_be_removed.append(entry.entity_id) @@ -195,7 +218,9 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: if ( len( er.async_entries_for_device( - entity_registry, device_id, include_disabled_entities=True + entity_registry, + device_id, + include_disabled_entities=True, ) ) == 0 diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 2d21faa073b3..9789b4b464a0 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -96,6 +96,23 @@ "remote_turned_counter_clockwise": "Device turned counterclockwise" } }, + "exceptions": { + "configure_failed": { + "message": "Failed to configure device" + }, + "device_refresh_failed": { + "message": "Failed to refresh devices" + }, + "entity_not_found": { + "message": "Could not find entity {entity_id}" + }, + "gateway_not_found": { + "message": "Could not find gateway {bridge_id}" + }, + "no_master_gateway": { + "message": "No master gateway available" + } + }, "options": { "step": { "deconz_devices": { diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py index ad036b68e001..bca5b30f9aa2 100644 --- a/tests/components/deconz/conftest.py +++ b/tests/components/deconz/conftest.py @@ -87,16 +87,27 @@ def fixture_config_entry_source() -> str: @pytest.fixture(name="mock_put_request") def fixture_put_request( - aioclient_mock: AiohttpClientMocker, config_entry_data: MappingProxyType[str, Any] -) -> Callable[[str, str], AiohttpClientMocker]: + aioclient_mock: AiohttpClientMocker, + config_entry_data: MappingProxyType[str, Any], +) -> Callable[..., AiohttpClientMocker]: """Mock a deCONZ put request.""" _host = config_entry_data[CONF_HOST] _port = config_entry_data[CONF_PORT] _api_key = config_entry_data[CONF_API_KEY] - def __mock_requests(path: str, host: str = "") -> AiohttpClientMocker: + def __mock_requests( + path: str, + host: str = "", + *, + exc: Exception | type[Exception] | None = None, + ) -> AiohttpClientMocker: url = f"http://{host or _host}:{_port}/api/{_api_key}{path}" - aioclient_mock.put(url, json={}, headers={"content-type": CONTENT_TYPE_JSON}) + aioclient_mock.put( + url, + json={}, + exc=exc, + headers={"content-type": CONTENT_TYPE_JSON}, + ) return aioclient_mock return __mock_requests @@ -129,14 +140,17 @@ def fixture_get_request( sensor_payload = {"0": sensor_payload} data.setdefault("sensors", sensor_payload) - def __mock_requests(host: str = "") -> None: + def __mock_requests( + host: str = "", + *, + exc: Exception | type[Exception] | None = None, + ) -> None: url = f"http://{host or _host}:{_port}/api/{_api_key}" aioclient_mock.get( url, json=deconz_payload | {"config": config_payload}, - headers={ - "content-type": CONTENT_TYPE_JSON, - }, + exc=exc, + headers={"content-type": CONTENT_TYPE_JSON}, ) return __mock_requests diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 32a6510db08c..0ebb4bb191d3 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -3,6 +3,7 @@ from collections.abc import Callable from typing import Any +from pydeconz.errors import RequestError import pytest import voluptuous as vol @@ -22,6 +23,7 @@ from homeassistant.components.deconz.services import ( ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from .test_hub import BRIDGE_ID @@ -111,7 +113,8 @@ async def test_configure_service_with_entity_and_field( @pytest.mark.usefixtures("config_entry_setup") async def test_configure_service_with_faulty_bridgeid( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test that service fails on a bad bridge id.""" aioclient_mock.clear_requests() @@ -122,9 +125,15 @@ async def test_configure_service_with_faulty_bridgeid( SERVICE_DATA: {"on": True}, } - await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + assert err.value.translation_key == "gateway_not_found" assert len(aioclient_mock.mock_calls) == 0 @@ -141,9 +150,10 @@ async def test_configure_service_with_faulty_field(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("config_entry_setup") async def test_configure_service_with_faulty_entity( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test that service on a non existing entity.""" + """Test that service fails on a non-existing entity.""" aioclient_mock.clear_requests() data = { @@ -151,16 +161,24 @@ async def test_configure_service_with_faulty_entity( SERVICE_DATA: {}, } - await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + assert err.value.translation_key == "entity_not_found" + assert err.value.translation_placeholders == {"entity_id": "light.nonexisting"} assert len(aioclient_mock.mock_calls) == 0 @pytest.mark.parametrize("config_entry_options", [{CONF_MASTER_GATEWAY: False}]) @pytest.mark.usefixtures("config_entry_setup") async def test_calling_service_with_no_master_gateway_fails( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test that service call fails when no master gateway exist.""" aioclient_mock.clear_requests() @@ -170,9 +188,15 @@ async def test_calling_service_with_no_master_gateway_fails( SERVICE_DATA: {"on": True}, } - await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + assert err.value.translation_key == "no_master_gateway" assert len(aioclient_mock.mock_calls) == 0 @@ -390,3 +414,57 @@ async def test_remove_orphaned_entries_service( ) == 2 # Light and switch battery ) + + +@pytest.mark.usefixtures("config_entry_setup") +async def test_configure_service_request_error( + hass: HomeAssistant, + mock_put_request: Callable[..., AiohttpClientMocker], +) -> None: + """Test configure service handles API request errors.""" + + data = { + SERVICE_FIELD: "/lights/2", + CONF_BRIDGE_ID: BRIDGE_ID, + SERVICE_DATA: {"on": True}, + } + + mock_put_request( + "/lights/2", + exc=RequestError("Request failed"), + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + + assert exc_info.value.translation_key == "configure_failed" + + +async def test_service_refresh_devices_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + mock_requests: Callable[..., None], +) -> None: + """Test refresh service handles request failures.""" + + aioclient_mock.clear_requests() + mock_requests(exc=TimeoutError) + + hub = config_entry_setup.runtime_data + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_DEVICE_REFRESH, + service_data={CONF_BRIDGE_ID: BRIDGE_ID}, + blocking=True, + ) + + assert exc_info.value.translation_key == "device_refresh_failed" + assert hub.ignore_state_updates is False From 479584f4593531972df4be9b3ead5b76dd8168e2 Mon Sep 17 00:00:00 2001 From: Markus Tuominen <3738613+Markus98@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:33:37 +0200 Subject: [PATCH 123/707] Require all rules to be listed in quality scale files (#175777) --- .../components/fluss/quality_scale.yaml | 1 + .../components/kiosker/quality_scale.yaml | 1 + .../components/omie/quality_scale.yaml | 26 +++++++++++++++++++ .../components/vistapool/quality_scale.yaml | 3 +++ script/hassfest/quality_scale.py | 2 +- 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fluss/quality_scale.yaml b/homeassistant/components/fluss/quality_scale.yaml index 371598661704..83aa1eba9f52 100644 --- a/homeassistant/components/fluss/quality_scale.yaml +++ b/homeassistant/components/fluss/quality_scale.yaml @@ -32,6 +32,7 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done + entity-unavailable: todo integration-owner: done log-when-unavailable: done parallel-updates: done diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 6b4e2e01cefb..92531353def7 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -39,6 +39,7 @@ rules: # Gold devices: done + diagnostics: todo discovery-update-info: todo discovery: done docs-data-update: done diff --git a/homeassistant/components/omie/quality_scale.yaml b/homeassistant/components/omie/quality_scale.yaml index 5290b86a3672..9b54a27a394a 100644 --- a/homeassistant/components/omie/quality_scale.yaml +++ b/homeassistant/components/omie/quality_scale.yaml @@ -49,3 +49,29 @@ rules: status: exempt comment: OMIE API is public data service that doesn't require authentication. test-coverage: done + # Gold + devices: todo + diagnostics: todo + discovery: todo + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 4452457150fd..b1d6aae349b9 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -35,9 +35,11 @@ rules: docs-configuration-parameters: status: exempt comment: No options flow + docs-installation-parameters: todo docs-troubleshooting: done entity-category: done entity-disabled-by-default: done + entity-unavailable: todo integration-owner: done log-when-unavailable: done parallel-updates: done @@ -58,6 +60,7 @@ rules: docs-supported-functions: done docs-use-cases: done dynamic-devices: todo + entity-device-class: todo entity-translations: done exception-translations: done icon-translations: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5add8bb55ee0..fc43a5b2d0c4 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2093,7 +2093,7 @@ SCHEMA = vol.Schema( { vol.Required("rules"): vol.Schema( { - vol.Optional(rule.name): vol.Any( + vol.Required(rule.name): vol.Any( vol.In(["todo", "done"]), vol.Schema( { From 5af897a457d61971a05d80bb2bbea5af4e7e1ad6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:47:26 +0200 Subject: [PATCH 124/707] Migrate timer entity attributes to StrEnum (#175754) --- homeassistant/components/timer/__init__.py | 20 +++++++++++++------- homeassistant/components/timer/const.py | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/timer/const.py diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 8bca15ad6978..90a72ec4119f 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -7,7 +7,7 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_ENTITY_ID, CONF_ICON, @@ -26,6 +26,8 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util import dt as dt_util +from .const import TimerEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "timer" @@ -256,16 +258,20 @@ class Timer(collection.CollectionEntity, RestoreEntity): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" attrs: dict[str, Any] = { - ATTR_DURATION: _format_timedelta(self._running_duration), - ATTR_EDITABLE: self.editable, - ATTR_LAST_TRANSITION: self._last_transition, + TimerEntityStateAttribute.DURATION: _format_timedelta( + self._running_duration + ), + TimerEntityStateAttribute.EDITABLE: self.editable, + TimerEntityStateAttribute.LAST_TRANSITION: self._last_transition, } if self._end is not None: - attrs[ATTR_FINISHES_AT] = self._end.isoformat() + attrs[TimerEntityStateAttribute.FINISHES_AT] = self._end.isoformat() if self._remaining is not None: - attrs[ATTR_REMAINING] = _format_timedelta(self._remaining) + attrs[TimerEntityStateAttribute.REMAINING] = _format_timedelta( + self._remaining + ) if self._restore: - attrs[ATTR_RESTORE] = self._restore + attrs[TimerEntityStateAttribute.RESTORE] = self._restore return attrs diff --git a/homeassistant/components/timer/const.py b/homeassistant/components/timer/const.py new file mode 100644 index 000000000000..93480189f2dc --- /dev/null +++ b/homeassistant/components/timer/const.py @@ -0,0 +1,14 @@ +"""Constants for the timer integration.""" + +from enum import StrEnum + + +class TimerEntityStateAttribute(StrEnum): + """State attributes for timer entities.""" + + DURATION = "duration" + EDITABLE = "editable" + LAST_TRANSITION = "last_transition" + FINISHES_AT = "finishes_at" + REMAINING = "remaining" + RESTORE = "restore" From c8b173368c9669708fe2c2da5b5eaad619d258a1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:08:15 +0200 Subject: [PATCH 125/707] Migrate counter entity attributes to StrEnum (#175738) --- homeassistant/components/counter/__init__.py | 13 +++++++------ homeassistant/components/counter/const.py | 13 +++++++++++++ tests/components/counter/test_init.py | 9 +++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/counter/const.py diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 2b5bf95042f6..28c535d7223d 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -6,7 +6,6 @@ from typing import Any, Self, override import voluptuous as vol from homeassistant.const import ( - ATTR_EDITABLE, CONF_ICON, CONF_ID, CONF_MAXIMUM, @@ -20,6 +19,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import CounterEntityStateAttribute + _LOGGER = logging.getLogger(__name__) ATTR_INITIAL = "initial" @@ -205,14 +206,14 @@ class Counter(collection.CollectionEntity, RestoreEntity): def extra_state_attributes(self) -> dict: """Return the state attributes.""" ret = { - ATTR_EDITABLE: self.editable, - ATTR_INITIAL: self._config[CONF_INITIAL], - ATTR_STEP: self._config[CONF_STEP], + CounterEntityStateAttribute.EDITABLE: self.editable, + CounterEntityStateAttribute.INITIAL: self._config[CONF_INITIAL], + CounterEntityStateAttribute.STEP: self._config[CONF_STEP], } if self._config[CONF_MINIMUM] is not None: - ret[CONF_MINIMUM] = self._config[CONF_MINIMUM] + ret[CounterEntityStateAttribute.MINIMUM] = self._config[CONF_MINIMUM] if self._config[CONF_MAXIMUM] is not None: - ret[CONF_MAXIMUM] = self._config[CONF_MAXIMUM] + ret[CounterEntityStateAttribute.MAXIMUM] = self._config[CONF_MAXIMUM] return ret @property diff --git a/homeassistant/components/counter/const.py b/homeassistant/components/counter/const.py new file mode 100644 index 000000000000..5d7b14e91fdf --- /dev/null +++ b/homeassistant/components/counter/const.py @@ -0,0 +1,13 @@ +"""Constants for the counter integration.""" + +from enum import StrEnum + + +class CounterEntityStateAttribute(StrEnum): + """State attributes for counter entities.""" + + EDITABLE = "editable" + INITIAL = "initial" + STEP = "step" + MINIMUM = "minimum" + MAXIMUM = "maximum" diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 61f63f4a6e9f..8ec76c557497 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -6,7 +6,6 @@ from typing import Any import pytest from homeassistant.components.counter import ( - ATTR_EDITABLE, ATTR_INITIAL, ATTR_MAXIMUM, ATTR_MINIMUM, @@ -24,7 +23,13 @@ from homeassistant.components.counter import ( SERVICE_SET_VALUE, VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME +from homeassistant.const import ( + ATTR_EDITABLE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_NAME, +) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component From 4d35c451bb65b4cd3137173a6856fff9d977d930 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 17:30:51 +0200 Subject: [PATCH 126/707] Remove previously deprecated battery props from tplink (#175764) --- homeassistant/components/tplink/vacuum.py | 7 ------- tests/components/tplink/snapshots/test_vacuum.ambr | 6 ++---- tests/components/tplink/test_vacuum.py | 2 -- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py index c77237fca686..c1d64ce0cf9e 100644 --- a/homeassistant/components/tplink/vacuum.py +++ b/homeassistant/components/tplink/vacuum.py @@ -94,7 +94,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): _attr_supported_features = ( VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.START | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME @@ -152,12 +151,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): """Locate the device.""" await self._speaker_module.locate() - @property - @override - def battery_level(self) -> int | None: - """Return battery level.""" - return self._vacuum_module.battery - @override def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index 19344d96c889..0d432cb0a014 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -70,7 +70,7 @@ 'platform': 'tplink', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': '123456789ABCDEFGH-vacuum', 'unit_of_measurement': None, @@ -79,15 +79,13 @@ # name: test_states[vacuum.my_vacuum-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-charging-100', - 'battery_level': 100, : 'max', : list([ 'quiet', 'max', ]), : 'my_vacuum', - : , + : , }), 'context': , 'entity_id': 'vacuum.my_vacuum', diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py index 649eaa7f5aea..3c7f165aae18 100644 --- a/tests/components/tplink/test_vacuum.py +++ b/tests/components/tplink/test_vacuum.py @@ -5,7 +5,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, DOMAIN as VACUUM_DOMAIN, SERVICE_LOCATE, @@ -62,7 +61,6 @@ async def test_vacuum( assert state.state == VacuumActivity.DOCKED assert state.attributes[ATTR_FAN_SPEED] == "max" - assert state.attributes[ATTR_BATTERY_LEVEL] == 100 result = translation.async_translate_state( hass, "max", "vacuum", "tplink", "vacuum.state_attributes.fan_speed", None ) From 3261a1fbfb27b3bda8bb92beeddea43ca368332d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jul 2026 17:38:04 +0200 Subject: [PATCH 127/707] Remove previously deprecated battery props from xiaomi_miio (#175687) --- homeassistant/components/xiaomi_miio/vacuum.py | 7 ------- tests/components/xiaomi_miio/test_vacuum.py | 7 ++----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 4146a59cfb31..87d428b9caef 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -92,7 +92,6 @@ class MiroboVacuum( | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.START ) @@ -114,12 +113,6 @@ class MiroboVacuum( return super().activity - @property - @override - def battery_level(self) -> int: - """Return the battery level of the vacuum cleaner.""" - return self.coordinator.data.status.battery - @property @override def fan_speed(self) -> str: diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 43820f8e933d..50cfb5f8b2fb 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -9,7 +9,6 @@ from miio import DeviceException import pytest from homeassistant.components.vacuum import ( - ATTR_BATTERY_ICON, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, DOMAIN as VACUUM_DOMAIN, @@ -263,9 +262,8 @@ async def test_xiaomi_vacuum_services( state = hass.states.get(entity_id) assert state.state == VacuumActivity.ERROR - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14140 assert state.attributes.get(ATTR_ERROR) == "Error message" - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-80" assert state.attributes.get(ATTR_TIMERS) == [ { "enabled": True, @@ -449,9 +447,8 @@ async def test_xiaomi_specific_services( # Check state attributes state = hass.states.get(entity_id) assert state.state == VacuumActivity.CLEANING - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14140 assert state.attributes.get(ATTR_ERROR) is None - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-30" assert state.attributes.get(ATTR_TIMERS) == [ { "enabled": True, From 680f414f766421b8b033d90dcc6f1820bfdc1917 Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Mon, 6 Jul 2026 17:41:15 +0200 Subject: [PATCH 128/707] Unload platforms in stiebel_eltron before closing connection in async_unload_entry (#175776) Co-authored-by: Claude Sonnet 4.6 --- .../components/stiebel_eltron/__init__.py | 6 +-- .../stiebel_eltron/quality_scale.yaml | 2 +- tests/components/stiebel_eltron/test_init.py | 41 ++++++++++++++++++- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 7802bb954b7b..187a7ae674af 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -45,6 +45,6 @@ async def async_unload_entry( entry: StiebelEltronConfigEntry, ) -> bool: """Unload a config entry.""" - coordinator = entry.runtime_data - await coordinator.close() - return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, _PLATFORMS): + await entry.runtime_data.close() + return unload_ok diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml index d476c182a829..183845abfa13 100644 --- a/homeassistant/components/stiebel_eltron/quality_scale.yaml +++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml @@ -31,7 +31,7 @@ rules: # Silver action-exceptions: todo - config-entry-unloading: todo + config-entry-unloading: done docs-configuration-parameters: status: exempt comment: Integration does not have an options flow. diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py index 44fa7259309a..c690ecb7a709 100644 --- a/tests/components/stiebel_eltron/test_init.py +++ b/tests/components/stiebel_eltron/test_init.py @@ -1,6 +1,6 @@ """Tests for the STIEBEL ELTRON integration.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pymodbus.exceptions import ModbusException from pystiebeleltron import StiebelEltronModbusError @@ -89,3 +89,42 @@ async def test_async_setup_entry_coordinator_update_fails( assert result is False assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry_closes_connection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, +) -> None: + """Test unloading the config entry closes the Modbus connection.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_lwz_api.close.assert_awaited_once() + + +async def test_unload_entry_does_not_close_connection_if_platform_unload_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, +) -> None: + """Test the connection is not closed if platform unload fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=False, + ): + result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + mock_lwz_api.close.assert_not_awaited() From c89cde8837da2f29b98af22c30fc72ba29499fb6 Mon Sep 17 00:00:00 2001 From: TimL Date: Tue, 7 Jul 2026 02:25:23 +1000 Subject: [PATCH 129/707] Add infrared receiver for SMLIGHT Ultima devices (#175188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: abmantis Co-authored-by: Abílio Costa --- homeassistant/components/smlight/infrared.py | 41 +++++++++++++++-- tests/components/smlight/test_infrared.py | 47 ++++++++++++++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py index 0e9859473128..02de21ab7825 100644 --- a/homeassistant/components/smlight/infrared.py +++ b/homeassistant/components/smlight/infrared.py @@ -2,11 +2,17 @@ from typing import override +from pysmlight.const import Events as SmEvents from pysmlight.exceptions import SmlightError from pysmlight.models import IRPayload -from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity -from homeassistant.core import HomeAssistant +from homeassistant.components.infrared import ( + InfraredCommand, + InfraredEmitterEntity, + InfraredReceivedSignal, + InfraredReceiverEntity, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -26,7 +32,12 @@ async def async_setup_entry( coordinator = entry.runtime_data.data if coordinator.data.info.has_peripherals: - async_add_entities([SmInfraredEntity(coordinator)]) + async_add_entities( + [ + SmInfraredEntity(coordinator), + SmInfraredReceiverEntity(coordinator), + ] + ) class SmInfraredEntity(SmEntity, InfraredEmitterEntity): @@ -59,3 +70,27 @@ class SmInfraredEntity(SmEntity, InfraredEmitterEntity): translation_key="send_ir_code_failed", translation_placeholders={"error": str(err)}, ) from err + + +class SmInfraredReceiverEntity(SmEntity, InfraredReceiverEntity): + """Representation of a SLZB-Ultima infrared receiver.""" + + def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + """Initialize the SLZB-Ultima infrared receiver.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}-receiver" + + @override + async def async_added_to_hass(self) -> None: + """Register SSE callbacks when entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.client.sse.register_callback( + SmEvents.IR_CODE, self._handle_ir_code + ) + ) + + @callback + def _handle_ir_code(self, timings: list[int]) -> None: + """Handle received IR code.""" + self._handle_received_signal(InfraredReceivedSignal(timings=timings)) diff --git a/tests/components/smlight/test_infrared.py b/tests/components/smlight/test_infrared.py index 4b1b4b96f477..ac4ec0d0c330 100644 --- a/tests/components/smlight/test_infrared.py +++ b/tests/components/smlight/test_infrared.py @@ -3,15 +3,20 @@ from unittest.mock import MagicMock from infrared_protocols.commands import Command +from pysmlight.const import Events as SmEvents from pysmlight.exceptions import SmlightError from pysmlight.models import IRPayload import pytest -from homeassistant.components.infrared import async_send_command +from homeassistant.components.infrared import ( + async_send_command, + async_subscribe_receiver, +) from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from . import get_mock_event_function from .conftest import setup_integration from tests.common import MockConfigEntry @@ -40,24 +45,30 @@ async def test_infrared_setup_ultima( mock_config_entry: MockConfigEntry, mock_ultima_client: MagicMock, ) -> None: - """Test infrared entity is created for Ultima devices.""" + """Test infrared entities are created for Ultima devices.""" await setup_integration(hass, mock_config_entry) state = hass.states.get("infrared.mock_title_ir_emitter") assert state is not None + state = hass.states.get("infrared.mock_title_infrared_receiver") + assert state is not None + async def test_infrared_not_created_non_ultima( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, ) -> None: - """Test infrared entity is not created for non-Ultima devices.""" + """Test infrared entities are not created for non-Ultima devices.""" await setup_integration(hass, mock_config_entry) state = hass.states.get("infrared.mock_title_ir_emitter") assert state is None + state = hass.states.get("infrared.mock_title_infrared_receiver") + assert state is None + async def test_infrared_send_command( hass: HomeAssistant, @@ -146,3 +157,33 @@ async def test_infrared_state_updated_after_send( state = hass.states.get(entity_id) assert state.state == "2025-09-03T22:00:00.000+00:00" + + +@pytest.mark.freeze_time("2025-09-03T22:00:00+00:00") +async def test_infrared_receiver_event( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ultima_client: MagicMock, +) -> None: + """Test infrared receiver receives event and updates state.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "infrared.mock_title_infrared_receiver" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + signals = [] + async_subscribe_receiver(hass, entity_id, signals.append) + + event_function = get_mock_event_function(mock_ultima_client, SmEvents.IR_CODE) + assert event_function is not None + + event_function([9000, 4500, 560, 1690]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "2025-09-03T22:00:00.000+00:00" + + assert len(signals) == 1 + assert signals[0].timings == [9000, 4500, 560, 1690] From 3cfa76783afa0298401cdaacc76d326cbd4cb5f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 13:29:03 -0500 Subject: [PATCH 130/707] Bump onvif-zeep-async to 4.2.1 (#175795) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index d29370d58d8b..c41c0e7de568 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -14,7 +14,7 @@ "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], "requirements": [ - "onvif-zeep-async==4.2.0", + "onvif-zeep-async==4.2.1", "onvif_parsers==2.3.0", "WSDiscovery==2.1.2" ] diff --git a/requirements_all.txt b/requirements_all.txt index 932e6a5cb19f..416a61a8f670 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1743,7 +1743,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.1.7 # homeassistant.components.onvif -onvif-zeep-async==4.2.0 +onvif-zeep-async==4.2.1 # homeassistant.components.onvif onvif_parsers==2.3.0 From 3fb99a4bc2cc67da8f1587e4e8c87eb42c50f164 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jul 2026 13:29:22 -0500 Subject: [PATCH 131/707] Bump bleak-esphome to 3.9.7 (#175796) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 6328164acb87..867c4c473c47 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -19,7 +19,7 @@ "requirements": [ "aioesphomeapi==45.5.2", "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.9.4" + "bleak-esphome==3.9.7" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 416a61a8f670..e3bd63ba8199 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -648,7 +648,7 @@ beautifulsoup4==4.13.3 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==3.9.4 +bleak-esphome==3.9.7 # homeassistant.components.bluetooth bleak-retry-connector==4.6.1 From 53ed1252e629de0dd9e93b0372e19e9b2392f2f8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Jul 2026 12:12:21 -0700 Subject: [PATCH 132/707] Fix for Roborock uninitialized coordinator data (#175789) --- homeassistant/components/roborock/__init__.py | 15 ++-- homeassistant/components/roborock/sensor.py | 1 - .../components/roborock/test_binary_sensor.py | 54 ++++++++++++++- tests/components/roborock/test_sensor.py | 68 ++++++++++++++++++- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 899e54f45ed8..0472eb35d892 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -277,17 +277,22 @@ async def async_setup_device( _LOGGER.warning("Failed to close device %s: %s", device.duid, err) return + try: + await coordinator.async_refresh() + except RoborockException as err: + _LOGGER.error( + "Failed initial attempt to connect to device %s (%s): %s", + device.name, + device.duid, + err, + ) + entry.runtime_data.add(coordinator) async_dispatcher_send( hass, f"roborock_coordinator_added_{entry.entry_id}", coordinator, ) - entry.async_create_background_task( - hass, - coordinator.async_refresh(), - name=f"roborock_coordinator_refresh_{coordinator.duid}", - ) async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 3952d8d8bad8..86f113bf40ef 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -570,7 +570,6 @@ async def async_setup_entry( entities.extend( RoborockSensorEntityB01Q7(coordinator, description) for description in Q7_B01_SENSOR_DESCRIPTIONS - if description.value_fn(coordinator.data) is not None ) elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator): entities.extend( diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index d1c2ba039b51..ab5e7d44bcc3 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -1,12 +1,17 @@ """Test Roborock Binary Sensor.""" +from typing import Any + import pytest +from roborock.exceptions import RoborockException from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import FakeDevice + from tests.common import MockConfigEntry, snapshot_platform @@ -24,3 +29,50 @@ async def test_binary_sensors( ) -> None: """Test binary sensors and check test values are correctly set.""" await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) + + +def setup_coordinator_side_effect( + fake_devices: list[FakeDevice], side_effect: Any +) -> None: + """Set the query/refresh side effect on all fake devices to simulate failure or delay.""" + for device in fake_devices: + if device.v1_properties is not None: + device.v1_properties.status.refresh.side_effect = side_effect + if device.dyad is not None: + device.dyad.query_values.side_effect = side_effect + if device.zeo is not None: + device.zeo.query_values.side_effect = side_effect + if device.b01_q10_properties is not None: + device.b01_q10_properties.refresh.side_effect = side_effect + if device.b01_q7_properties is not None: + device.b01_q7_properties.query_values.side_effect = side_effect + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (RoborockException("Simulated failure"), STATE_UNAVAILABLE), + ], +) +async def test_binary_sensors_coordinator_state( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], + side_effect: Any, + expected_state: str, +) -> None: + """Test binary sensors state based on coordinator update success or delay.""" + setup_coordinator_side_effect(fake_devices, side_effect) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # V1 binary sensors + state = hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached") + assert state is not None + assert state.state == expected_state + + # A01 (Dyad/Zeo) binary sensors + state = hass.states.get("binary_sensor.zeo_one_detergent") + assert state is not None + assert state.state == expected_state diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 7b14fec62042..fa2b9702bef4 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -1,12 +1,17 @@ """Test Roborock Sensors.""" +from typing import Any + import pytest +from roborock.exceptions import RoborockException from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import FakeDevice + from tests.common import MockConfigEntry, snapshot_platform @@ -24,3 +29,64 @@ async def test_sensors( ) -> None: """Test sensors and check test values are correctly set.""" await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) + + +def setup_coordinator_side_effect( + fake_devices: list[FakeDevice], side_effect: Any +) -> None: + """Set the query/refresh side effect on all fake devices to simulate failure or delay.""" + for device in fake_devices: + if device.v1_properties is not None: + device.v1_properties.status.refresh.side_effect = side_effect + if device.dyad is not None: + device.dyad.query_values.side_effect = side_effect + if device.zeo is not None: + device.zeo.query_values.side_effect = side_effect + if device.b01_q10_properties is not None: + device.b01_q10_properties.refresh.side_effect = side_effect + if device.b01_q7_properties is not None: + device.b01_q7_properties.query_values.side_effect = side_effect + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (RoborockException("Simulated failure"), STATE_UNAVAILABLE), + ], +) +async def test_sensors_coordinator_state( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], + side_effect: Any, + expected_state: str, +) -> None: + """Test sensors state based on coordinator update success or delay.""" + setup_coordinator_side_effect(fake_devices, side_effect) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # V1 sensors + state = hass.states.get("sensor.roborock_s7_maxv_battery") + assert state is not None + assert state.state == expected_state + + # A01 (Dyad/Zeo) sensors + state = hass.states.get("sensor.dyad_pro_battery") + assert state is not None + assert state.state == expected_state + + state = hass.states.get("sensor.zeo_one_washing_left") + assert state is not None + assert state.state == expected_state + + # B01 Q7 sensors + state = hass.states.get("sensor.roborock_q7_battery") + assert state is not None + assert state.state == expected_state + + # B01 Q10 sensors + state = hass.states.get("sensor.roborock_q10_s5_battery") + assert state is not None + assert state.state == expected_state From 7838fd2903e9027b5b4d167a18392aebd0fd748a Mon Sep 17 00:00:00 2001 From: Manu Date: Mon, 6 Jul 2026 21:59:05 +0200 Subject: [PATCH 133/707] Fix missing `To` headers in SMTP integration (#175803) --- homeassistant/components/smtp/notify.py | 3 +++ tests/components/smtp/test_notify.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 15b65b36ea39..f63ce6d2ab8f 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -201,6 +201,9 @@ class MailNotifyEntity(NotifyEntity): msg["From"] = email.utils.formataddr( (self._entry.data.get(CONF_SENDER_NAME), self._entry.data[CONF_SENDER]) ) + msg["To"] = email.utils.formataddr( + (self._subentry.title, self._subentry.unique_id) + ) msg["X-Mailer"] = "Home Assistant" msg["Date"] = email.utils.format_datetime(dt_util.now()) msg["Message-Id"] = email.utils.make_msgid() diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 77b1569b56a3..3ebebfed260a 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -278,6 +278,7 @@ async def test_notify_send_message( "Content-Transfer-Encoding: 7bit\n" "Subject: Home Assistant\n" "From: Home Assistant \n" + "To: Recipient \n" "X-Mailer: Home Assistant\n" "Date: Sat, 02 May 2026 20:09:37 -0700\n" "Message-Id: <177777777700.12345.12345678901234567890@mock>\n\n" From 9f32a51d1278d0c870dc97f088f393cda458f5f2 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 6 Jul 2026 22:07:54 +0200 Subject: [PATCH 134/707] Fix temperature bound resolution for BSBLAN climate devices (#175704) --- homeassistant/components/bsblan/climate.py | 31 +++++++-- tests/components/bsblan/test_climate.py | 80 +++++++++++++++++++++- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 094a74f855f1..4f1ea98d1939 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -2,7 +2,7 @@ from typing import Any, Final, override -from bsblan import BSBLANError, State, get_hvac_action_category +from bsblan import BSBLANError, EntityInfo, State, get_hvac_action_category from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -54,6 +54,14 @@ BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = { } +def _resolve_temperature_bound(*sources: EntityInfo[float] | None) -> float | None: + """Return the first usable temperature bound from the given sources.""" + for source in sources: + if source is not None and source.value is not None: + return source.value + return None + + async def async_setup_entry( hass: HomeAssistant, entry: BSBLanConfigEntry, @@ -97,12 +105,23 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity): else: self._attr_unique_id = f"{mac}-climate-{circuit}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform - # Set temperature range from per-circuit static data + # Set temperature range from per-circuit static data. Standard BSB/LPB + # circuits expose the bounds via heating_protective_setpoint (714) and + # comfort_setpoint_max (716); min_temp/max_temp (15006/15007) exist + # only on PPS devices. Inactive parameters ("---") have value None. if (static := data.static.get(circuit)) is not None: - if (min_temp := static.min_temp) is not None and min_temp.value is not None: - self._attr_min_temp = min_temp.value - if (max_temp := static.max_temp) is not None and max_temp.value is not None: - self._attr_max_temp = max_temp.value + if ( + min_temp := _resolve_temperature_bound( + static.heating_protective_setpoint, static.min_temp + ) + ) is not None: + self._attr_min_temp = min_temp + if ( + max_temp := _resolve_temperature_bound( + static.comfort_setpoint_max, static.max_temp + ) + ) is not None: + self._attr_max_temp = max_temp self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit @property diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 7cff47e68598..e0fe8c1d5dbb 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -1,9 +1,10 @@ """Tests for the BSB-LAN climate platform.""" from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock -from bsblan import BSBLANError, HeatingCircuitStatus +from bsblan import BSBLANError, HeatingCircuitStatus, StaticState from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -12,6 +13,8 @@ from homeassistant.components.bsblan.const import DOMAIN from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, DOMAIN as CLIMATE_DOMAIN, PRESET_ECO, PRESET_NONE, @@ -33,6 +36,81 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat ENTITY_ID = "climate.heating_circuit_1" +def _temp_param(value: str) -> dict[str, Any]: + """Build a raw BSB-LAN temperature parameter payload.""" + return { + "name": "", + "value": value, + "unit": "°C", + "desc": "", + "dataType": 0, + "readonly": 0, + "error": 0, + } + + +@pytest.mark.parametrize( + ("static_data", "expected_min", "expected_max"), + [ + pytest.param( + { + "heating_protective_setpoint": _temp_param("10.0"), + "comfort_setpoint_max": _temp_param("26.0"), + }, + 10.0, + 26.0, + id="standard_device", + ), + pytest.param( + { + "min_temp": _temp_param("8.0"), + "max_temp": _temp_param("20.0"), + }, + 8.0, + 20.0, + id="pps_device", + ), + pytest.param( + { + "heating_protective_setpoint": _temp_param("---"), + "comfort_setpoint_max": _temp_param("---"), + "min_temp": _temp_param("8.0"), + "max_temp": _temp_param("20.0"), + }, + 8.0, + 20.0, + id="inactive_preferred_source", + ), + pytest.param( + { + "heating_protective_setpoint": _temp_param("---"), + "comfort_setpoint_max": _temp_param("---"), + }, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + id="all_sources_inactive", + ), + ], +) +async def test_climate_min_max_temperature( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + static_data: dict[str, Any], + expected_min: float, + expected_max: float, +) -> None: + """Test min/max temperature bounds resolved from per-circuit static values.""" + mock_bsblan.static_values.return_value = StaticState.model_validate(static_data) + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == expected_min + assert state.attributes["max_temp"] == expected_max + + async def test_celsius_fahrenheit( hass: HomeAssistant, mock_bsblan: AsyncMock, From d966812534a1ff73acceb02acec5d2c0d0aa63bf Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Jul 2026 22:34:15 +0200 Subject: [PATCH 135/707] Portainer refactor services patterns (#175549) --- .../components/portainer/services.py | 106 ++++++++---------- tests/components/portainer/test_services.py | 9 +- 2 files changed, 50 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index b1dd01675c0c..6ed749d5abff 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -10,10 +10,13 @@ from pyportainer import ( import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.service import async_extract_config_entry_ids +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + service, +) from .const import DOMAIN from .coordinator import PortainerConfigEntry @@ -47,39 +50,39 @@ SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema( ) -async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry: - """Extract config entry from the service call.""" - target_entry_ids = await async_extract_config_entry_ids(service_call) - target_entries: list[PortainerConfigEntry] = [ - loaded_entry - for loaded_entry in service_call.hass.config_entries.async_loaded_entries( - DOMAIN - ) - if loaded_entry.entry_id in target_entry_ids - ] - if not target_entries: +@callback +def _async_get_device(call: ServiceCall, device_id: str) -> dr.DeviceEntry: + """Get a device entry from a device ID.""" + device_reg = dr.async_get(call.hass) + if (device := device_reg.async_get(device_id)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_target", ) - return target_entries[0] + return device -async def _get_endpoint_id( - call: ServiceCall, +@callback +def _async_get_entry_from_device( + call: ServiceCall, device: dr.DeviceEntry +) -> PortainerConfigEntry: + """Resolve and validate the Portainer config entry for a device.""" + for entry in call.hass.config_entries.async_entries(DOMAIN): + if entry.entry_id in device.config_entries: + return service.async_get_config_entry(call.hass, DOMAIN, entry.entry_id) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + + +@callback +def _async_get_endpoint_id( + device: dr.DeviceEntry, config_entry: PortainerConfigEntry, ) -> int: - """Get endpoint data from device ID.""" - device_reg = dr.async_get(call.hass) - device_id = call.data[ATTR_DEVICE_ID] - device = device_reg.async_get(device_id) - - if device is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_target", - ) - + """Get the endpoint ID from a device entry.""" coordinator = config_entry.runtime_data for data in coordinator.data.values(): @@ -95,39 +98,21 @@ async def _get_endpoint_id( ) -async def _get_container_and_endpoint_ids( - call: ServiceCall, -) -> tuple[PortainerConfigEntry, int, str]: - """Get config entry, endpoint ID and container ID from the container device ID.""" - device_reg = dr.async_get(call.hass) - device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID]) - - if device is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_target", - ) - - config_entry: PortainerConfigEntry | None = None - for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN): - if loaded_entry.entry_id in device.config_entries: - config_entry = loaded_entry - break - - if config_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_target", - ) - +@callback +def _async_get_container_and_endpoint_ids( + device: dr.DeviceEntry, + config_entry: PortainerConfigEntry, +) -> tuple[int, str]: + """Get the endpoint ID and container ID from a container device entry.""" coordinator = config_entry.runtime_data + for data in coordinator.data.values(): for container_name, container_data in data.containers.items(): if ( DOMAIN, f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}", ) in device.identifiers: - return config_entry, data.endpoint.id, container_data.container.id + return data.endpoint.id, container_data.container.id raise ServiceValidationError( translation_domain=DOMAIN, @@ -137,9 +122,10 @@ async def _get_container_and_endpoint_ids( async def prune_images(call: ServiceCall) -> None: """Prune unused images in Portainer, with more controls.""" - config_entry = await _extract_config_entry(call) + device = _async_get_device(call, call.data[ATTR_DEVICE_ID]) + config_entry = _async_get_entry_from_device(call, device) coordinator = config_entry.runtime_data - endpoint_id = await _get_endpoint_id(call, config_entry) + endpoint_id = _async_get_endpoint_id(device, config_entry) try: await coordinator.portainer.images_prune( @@ -166,10 +152,12 @@ async def prune_images(call: ServiceCall) -> None: async def recreate_container(call: ServiceCall) -> None: """Recreate a container in Portainer, with more controls.""" - config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids( - call - ) + device = _async_get_device(call, call.data[ATTR_CONTAINER_DEVICE_ID]) + config_entry = _async_get_entry_from_device(call, device) coordinator = config_entry.runtime_data + endpoint_id, container_id = _async_get_container_and_endpoint_ids( + device, config_entry + ) timeout: timedelta | None = call.data.get(ATTR_TIMEOUT) try: diff --git a/tests/components/portainer/test_services.py b/tests/components/portainer/test_services.py index e685e5036fd6..f5c3ecc0bff0 100644 --- a/tests/components/portainer/test_services.py +++ b/tests/components/portainer/test_services.py @@ -20,7 +20,7 @@ from homeassistant.components.portainer.services import ( ATTR_TIMEOUT, SERVICE_PRUNE_IMAGES, SERVICE_RECREATE_CONTAINER, - _get_endpoint_id, + _async_get_device, ) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant @@ -297,17 +297,14 @@ async def test_service_prune_images_device_gone( mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test _get_endpoint_id raises when the device ID no longer exists in the registry.""" + """Test _async_get_device raises when the device ID no longer exists in the registry.""" await setup_integration(hass, mock_config_entry) - loaded_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - assert loaded_entry is not None mock_call = MagicMock() mock_call.hass = hass - mock_call.data = {ATTR_DEVICE_ID: "nonexistent_device_id"} with pytest.raises(ServiceValidationError): - await _get_endpoint_id(mock_call, loaded_entry) + _async_get_device(mock_call, "nonexistent_device_id") mock_portainer_client.images_prune.assert_not_called() From 6af492571e6c646f84fe41b8bde4193c3ebe5724 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 6 Jul 2026 22:35:03 +0200 Subject: [PATCH 136/707] Add myself as codeowner of Mikrotik (#175530) --- CODEOWNERS | 4 ++-- homeassistant/components/mikrotik/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 18e1f06d2eb4..3b00a23d0592 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1117,8 +1117,8 @@ CLAUDE.md @home-assistant/core /tests/components/microbees/ @microBeesTech /homeassistant/components/miele/ @astrandb /tests/components/miele/ @astrandb -/homeassistant/components/mikrotik/ @engrbm87 -/tests/components/mikrotik/ @engrbm87 +/homeassistant/components/mikrotik/ @engrbm87 @chemelli74 +/tests/components/mikrotik/ @engrbm87 @chemelli74 /homeassistant/components/mill/ @danielhiversen /tests/components/mill/ @danielhiversen /homeassistant/components/min_max/ @gjohansson-ST diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 3864af1f18c6..3234575d3277 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -1,7 +1,7 @@ { "domain": "mikrotik", "name": "Mikrotik", - "codeowners": ["@engrbm87"], + "codeowners": ["@engrbm87", "@chemelli74"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "integration_type": "device", From 5a7b3054ed80a8ffac1dd0be0d93cdc3476b1e93 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 6 Jul 2026 14:36:36 -0700 Subject: [PATCH 137/707] Bump total_connect_client to 2026.7 (#175807) --- homeassistant/components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 699bb8a7d762..af29c09a021a 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2025.12.2"] + "requirements": ["total-connect-client==2026.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e3bd63ba8199..ba57845761fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3195,7 +3195,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.12.2 +total-connect-client==2026.7 # homeassistant.components.tplink_omada tplink-omada-client==1.5.8 From 2ad15d7f3d2e8317fc3cc45af99bc57a764d3963 Mon Sep 17 00:00:00 2001 From: Manu Date: Mon, 6 Jul 2026 23:39:29 +0200 Subject: [PATCH 138/707] Add debug logging with full exception details to legacy notify action in SMTP integration (#175781) --- homeassistant/components/smtp/notify.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index f63ce6d2ab8f..4f887ae3b448 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -321,13 +321,17 @@ class MailNotificationService(SmtpClient, BaseNotificationService): break except SMTPServerDisconnected: _LOGGER.warning( - "SMTPServerDisconnected sending mail: retrying connection" + "SMTPServerDisconnected sending mail: retrying connection", + exc_info=_LOGGER.isEnabledFor(logging.DEBUG), ) with suppress(SMTPException): mail.quit() mail = self.connect() except SMTPException: - _LOGGER.warning("SMTPException sending mail: retrying connection") + _LOGGER.warning( + "SMTPException sending mail: retrying connection", + exc_info=_LOGGER.isEnabledFor(logging.DEBUG), + ) with suppress(SMTPException): mail.quit() mail = self.connect() From ea131806ba24d587f75d1cf261f3523c23f00dc7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Jul 2026 08:02:57 +0200 Subject: [PATCH 139/707] Fix for Atlantic heaters missing regulation mode in Overkiz (#175817) --- ...er_with_adjustable_temperature_setpoint.py | 14 +- .../setup/cloud_atlantic_cozytouch.json | 644 ++++++++++++++++++ .../overkiz/snapshots/test_climate.ambr | 423 ++++++++++++ .../overkiz/snapshots/test_sensor.ambr | 58 ++ tests/components/overkiz/test_climate.py | 36 + 5 files changed, 1170 insertions(+), 5 deletions(-) create mode 100644 tests/components/overkiz/snapshots/test_climate.ambr diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 099eb00ba0bb..8b813372fd35 100644 --- a/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -105,9 +105,11 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" states = self.device.states - if (state := states[OverkizState.CORE_OPERATING_MODE]) and state.value_as_str: + if ( + state := states.get(OverkizState.CORE_OPERATING_MODE) + ) and state.value_as_str: return OVERKIZ_TO_HVAC_MODE[state.value_as_str] - if (state := states[OverkizState.CORE_ON_OFF]) and state.value_as_str: + if (state := states.get(OverkizState.CORE_ON_OFF)) and state.value_as_str: return OVERKIZ_TO_HVAC_MODE[state.value_as_str] return HVACMode.OFF @@ -123,7 +125,9 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( def hvac_action(self) -> HVACAction: """Return the current running hvac operation ie. heating, idle, off.""" states = self.device.states - if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str: + if ( + state := states.get(OverkizState.CORE_REGULATION_MODE) + ) and state.value_as_str: return OVERKIZ_TO_HVAC_ACTION[state.value_as_str] return HVACAction.OFF @@ -135,12 +139,12 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( states = self.device.states if ( - state := states[OverkizState.IO_TARGET_HEATING_LEVEL] + state := states.get(OverkizState.IO_TARGET_HEATING_LEVEL) ) and state.value_as_str: return OVERKIZ_TO_PRESET_MODE[state.value_as_str] if ( - operating_mode := states[OverkizState.CORE_OPERATING_MODE] + operating_mode := states.get(OverkizState.CORE_OPERATING_MODE) ) and operating_mode.value_as_str == OverkizCommandParam.EXTERNAL: return PRESET_EXTERNAL return None diff --git a/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json b/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json index 2c7d389eb572..fc13e28ff9ff 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json +++ b/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json @@ -1598,6 +1598,650 @@ "type": 5, "uiClass": "ProtocolGateway", "widget": "IOStack" + }, + { + "creationTime": 1673778881000, + "lastUpdateTime": 1673778881000, + "label": "Living room heater", + "deviceURL": "modbuslink://1234-5678-5643/1#1", + "shortcut": false, + "controllableName": "modbuslink:AtlanticElectricalHeaterWithAdjustableTemperatureSetpointMBLComponent", + "definition": { + "commands": [ + { + "commandName": "off", + "nparams": 0 + }, + { + "commandName": "on", + "nparams": 0 + }, + { + "commandName": "refreshAbsenceEndDate", + "nparams": 0 + }, + { + "commandName": "refreshAbsenceMode", + "nparams": 0 + }, + { + "commandName": "refreshAbsenceStartDate", + "nparams": 0 + }, + { + "commandName": "refreshBoostActivation", + "nparams": 0 + }, + { + "commandName": "refreshDateTime", + "nparams": 0 + }, + { + "commandName": "refreshErrorCode", + "nparams": 0 + }, + { + "commandName": "refreshFridayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshHeatingLevel", + "nparams": 0 + }, + { + "commandName": "refreshIdentifier", + "nparams": 0 + }, + { + "commandName": "refreshLanguage", + "nparams": 0 + }, + { + "commandName": "refreshMaximumHeatingTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshMondayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshOccupancyActivation", + "nparams": 0 + }, + { + "commandName": "refreshOnOffState", + "nparams": 0 + }, + { + "commandName": "refreshOperatingMode", + "nparams": 0 + }, + { + "commandName": "refreshSaturdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshSundayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshThursdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshTuesdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshWednesdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "setAbsenceEndDate", + "nparams": 1 + }, + { + "commandName": "setAbsenceMode", + "nparams": 1 + }, + { + "commandName": "setAbsenceStartDate", + "nparams": 1 + }, + { + "commandName": "setBoostActivation", + "nparams": 1 + }, + { + "commandName": "setDateTime", + "nparams": 1 + }, + { + "commandName": "setHeatingLevel", + "nparams": 1 + }, + { + "commandName": "setLanguage", + "nparams": 1 + }, + { + "commandName": "setMaximumHeatingTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setMondayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setOccupancyActivation", + "nparams": 1 + }, + { + "commandName": "setOnOff", + "nparams": 1 + }, + { + "commandName": "setOperatingMode", + "nparams": 1 + }, + { + "commandName": "setSaturdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setSundayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setWednesdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "setFridayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setThursdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setTuesdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "refreshDiagnostic", + "nparams": 0 + }, + { + "commandName": "refreshSetpointLoweringTemperatureInProgMode", + "nparams": 0 + }, + { + "commandName": "refreshStaticStates1", + "nparams": 0 + }, + { + "commandName": "refreshStaticStates2", + "nparams": 0 + }, + { + "commandName": "setSetpointLoweringTemperatureInProgMode", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:AbsenceEndDateState" + }, + { + "type": "DataState", + "qualifiedName": "core:AbsenceStartDateState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:BoostActivationState" + }, + { + "type": "DataState", + "qualifiedName": "core:DateTimeState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorCodeState" + }, + { + "type": "DataState", + "qualifiedName": "core:FridayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:IdentifierState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:LanguageState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:MaximumHeatingTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:MondayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:OccupancyActivationState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:OccupancySensorStatusState" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "core:OnOffState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "DataState", + "qualifiedName": "core:SaturdayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:SundayTimeProgramState" + }, + { + "type": "DiscreteState", + "values": [ + "boost", + "comfort", + "comfort-1", + "comfort-2", + "eco", + "frostprotection", + "off", + "secured" + ], + "qualifiedName": "core:TargetHeatingLevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:ThursdayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:TuesdayTimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:VersionState" + }, + { + "type": "DataState", + "qualifiedName": "core:WednesdayTimeProgramState" + }, + { + "type": "DiscreteState", + "values": ["off", "on", "prog"], + "qualifiedName": "modbuslink:AbsenceModeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "modbuslink:SetpointLoweringTemperatureInProgModeState" + }, + { + "type": "DiscreteState", + "values": ["boost", "comfort", "eco"], + "qualifiedName": "modbuslink:TargetHeatingLevelState" + } + ], + "dataProperties": [], + "widgetName": "AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint", + "uiProfiles": [ + "StatefulHeatingLevel", + "HeatingLevel", + "StatefulThermostat", + "Thermostat", + "StatefulSwitchable", + "Switchable" + ], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "modbuslink:AtlanticElectricalHeaterWithAdjustableTemperatureSetpointMBLComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbuslink:AbsenceModeState", + "type": 3, + "value": "off" + }, + { + "name": "core:MaximumHeatingTargetTemperatureState", + "type": 2, + "value": 30.0 + }, + { + "name": "core:IdentifierState", + "type": 3, + "value": "off" + }, + { + "name": "core:MondayTimeProgramState", + "type": 11, + "value": { + "monday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:TuesdayTimeProgramState", + "type": 11, + "value": { + "tuesday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:WednesdayTimeProgramState", + "type": 11, + "value": { + "wednesday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "12:00", + "end": "14:00" + }, + { + "start": "18:00", + "end": "22:00" + } + ] + } + }, + { + "name": "core:ThursdayTimeProgramState", + "type": 11, + "value": { + "thursday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:FridayTimeProgramState", + "type": 11, + "value": { + "friday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:SaturdayTimeProgramState", + "type": 11, + "value": { + "saturday": [ + { + "start": "08:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:SundayTimeProgramState", + "type": 11, + "value": { + "sunday": [ + { + "start": "08:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 7.0 + }, + { + "name": "modbuslink:SetpointLoweringTemperatureInProgModeState", + "type": 1, + "value": 15 + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:BoostActivationState", + "type": 3, + "value": "active" + }, + { + "name": "core:OccupancyActivationState", + "type": 3, + "value": "active" + }, + { + "name": "modbuslink:TargetHeatingLevelState", + "type": 3, + "value": "comfort" + }, + { + "name": "core:TargetHeatingLevelState", + "type": 3, + "value": "comfort" + }, + { + "name": "core:OnOffState", + "type": 3, + "value": "off" + }, + { + "name": "core:OccupancySensorStatusState", + "type": 3, + "value": "active" + }, + { + "name": "core:VersionState", + "type": 1, + "value": 67 + }, + { + "name": "core:AbsenceEndDateState", + "type": 11, + "value": { + "month": 12, + "hour": 18, + "year": 2023, + "weekday": 4, + "day": 22, + "minute": 32, + "second": 39 + } + }, + { + "name": "core:AbsenceStartDateState", + "type": 11, + "value": { + "month": 12, + "hour": 20, + "year": 2023, + "weekday": 6, + "day": 17, + "minute": 22, + "second": 57 + } + } + ], + "available": true, + "enabled": true, + "widget": "AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint", + "type": 1, + "uiClass": "HeatingSystem" + }, + { + "creationTime": 1673778881000, + "lastUpdateTime": 1673778881000, + "label": "Living room temperature", + "deviceURL": "modbuslink://1234-5678-5643/1#2", + "shortcut": false, + "controllableName": "modbuslink:TemperatureInCelciusMBLSystemDeviceSensor", + "definition": { + "commands": [], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureState" + } + ], + "dataProperties": [], + "widgetName": "TemperatureSensor", + "uiProfiles": ["Temperature"], + "uiClass": "TemperatureSensor", + "qualifiedName": "modbuslink:TemperatureInCelciusMBLSystemDeviceSensor", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:TemperatureState", + "type": 2, + "value": 21.1 + } + ], + "attributes": [ + { + "name": "core:MeasuredValueType", + "type": 3, + "value": "core:TemperatureInCelcius" + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "mainSupply" + } + ], + "available": true, + "enabled": true, + "widget": "TemperatureSensor", + "type": 2, + "uiClass": "TemperatureSensor" } ], "features": [], diff --git a/tests/components/overkiz/snapshots/test_climate.ambr b/tests/components/overkiz/snapshots/test_climate.ambr new file mode 100644 index 000000000000..f86f59749643 --- /dev/null +++ b/tests/components/overkiz/snapshots/test_climate.ambr @@ -0,0 +1,423 @@ +# serializer version: 1 +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.living_room_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + ]), + : 35, + : 7, + : list([ + 'none', + 'frost_protection', + 'eco', + 'comfort', + 'comfort-1', + 'comfort-2', + 'auto', + 'boost', + 'external', + 'prog', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'modbuslink://1234-5678-5643/1#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.living_room_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 21.1, + : 'Living room heater', + : , + : list([ + , + , + , + ]), + : 35, + : 7, + : None, + : list([ + 'none', + 'frost_protection', + 'eco', + 'comfort', + 'comfort-1', + 'comfort-2', + 'auto', + 'boost', + 'external', + 'prog', + ]), + : , + : 7.0, + }), + 'context': , + 'entity_id': 'climate.living_room_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_garden_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_garden_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-1698/15702199#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_garden_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 24.4, + : 'Garden Radiator', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'comfort', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 21.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_garden_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_living_room_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_living_room_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-1698/9253412#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_living_room_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 21.5, + : 'Living Room Radiator', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'comfort', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 21.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_living_room_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_study_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_study_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-1698/9187218#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_study_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 20.6, + : 'Study Radiator', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'comfort', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 21.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_study_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_terrace_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + ]), + : 26.0, + : 15.0, + : list([ + 'away', + 'eco', + 'comfort', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_terrace_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ovp://1234-5678-1698/374762#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_terrace_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 23.3, + : 'Terrace Radiator', + : , + : list([ + , + , + , + ]), + : 26.0, + : 15.0, + : 'comfort', + : list([ + 'away', + 'eco', + 'comfort', + 'none', + ]), + : , + : 23.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_terrace_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/overkiz/snapshots/test_sensor.ambr b/tests/components/overkiz/snapshots/test_sensor.ambr index 8f265049f32e..8c8ec832617a 100644 --- a/tests/components/overkiz/snapshots/test_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_sensor.ambr @@ -1,4 +1,62 @@ # serializer version: 1 +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.living_room_temperature_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Living room temperature Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living room temperature Temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbuslink://1234-5678-5643/1#2-core:TemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.living_room_temperature_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living room temperature Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- # name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_patio_water_heating_bottom_tank_water_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py index b4e780d9b9f4..cddb4ad4586a 100644 --- a/tests/components/overkiz/test_climate.py +++ b/tests/components/overkiz/test_climate.py @@ -1,16 +1,19 @@ """Tests for the Overkiz climate platform.""" from collections.abc import Generator +from pathlib import Path from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from pyoverkiz.enums import OverkizState from pyoverkiz.models import Event import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ATTR_HVAC_ACTION, HVACAction from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration from .helpers import ( @@ -21,11 +24,25 @@ from .helpers import ( device_unavailable_event, ) +from tests.common import snapshot_platform + +# io:HeatingValveIOComponent VALVE = FixtureDevice( "setup/cloud_nexity_rail_din_europe.json", "io://1234-5678-1698/15702199#1", "climate.maple_residence_garden_radiator", ) +# modbuslink:AtlanticElectricalHeaterWithAdjustableTemperatureSetpointMBLComponent +COZYTOUCH = FixtureDevice( + "setup/cloud_atlantic_cozytouch.json", + "modbuslink://1234-5678-5643/1#1", + "climate.living_room_heater", +) + +SNAPSHOT_FIXTURES = [ + VALVE, + COZYTOUCH, +] @pytest.fixture(autouse=True) @@ -35,6 +52,25 @@ def fixture_platforms() -> Generator[None]: yield +@pytest.mark.parametrize( + "device", + SNAPSHOT_FIXTURES, + ids=[Path(device.fixture).name for device in SNAPSHOT_FIXTURES], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_entities_snapshot( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + device: FixtureDevice, +) -> None: + """Test representative real setups via snapshot.""" + config_entry = await setup_overkiz_integration(fixture=device.fixture) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_valve_hvac_action_none_state( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From 5833c5d01d9dadc6a8ed47f896ec2a287fc3a318 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 7 Jul 2026 16:03:50 +1000 Subject: [PATCH 140/707] Fix tesla_fleet media player volume step calculation (#175813) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/tesla_fleet/media_player.py | 11 +++---- .../tesla_fleet/test_media_player.py | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py index 0df38ebf15b5..3b2ebac8d4e2 100644 --- a/homeassistant/components/tesla_fleet/media_player.py +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -76,14 +76,11 @@ class TeslaFleetMediaEntity(TeslaFleetVehicleEntity, MediaPlayerEntity): self._attr_state = STATES.get( self.get("vehicle_state_media_info_media_playback_status") or "Off", ) + # volume_level is audio_volume / audio_volume_max, so one notch as a + # fraction of range is the per-notch increment divided by the max. self._attr_volume_step = ( - 1.0 - / self._volume_max - / ( - self.get("vehicle_state_media_info_audio_volume_increment") - or VOLUME_STEP - ) - ) + self.get("vehicle_state_media_info_audio_volume_increment") or VOLUME_STEP + ) / self._volume_max if volume := self.get("vehicle_state_media_info_audio_volume"): self._attr_volume_level = volume / self._volume_max diff --git a/tests/components/tesla_fleet/test_media_player.py b/tests/components/tesla_fleet/test_media_player.py index 3233246b8b5d..a164d2cbc08c 100644 --- a/tests/components/tesla_fleet/test_media_player.py +++ b/tests/components/tesla_fleet/test_media_player.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline @@ -13,6 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, MediaPlayerState, ) from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -37,6 +39,35 @@ async def test_media_player( assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) +async def test_media_player_volume_step( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, +) -> None: + """Test volume_up raises the level by exactly one Tesla notch.""" + + await setup_platform(hass, normal_config_entry, [Platform.MEDIA_PLAYER]) + + entity_id = "media_player.test_media_player" + + with patch( + "tesla_fleet_api.tesla.VehicleFleet.adjust_volume", + return_value=COMMAND_OK, + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # One notch up from the vehicle_data fixture's audio_volume of 1.6667 in a + # 10.333333 range: (1.6667 + 0.333333) / 10.333333. + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx( + 0.1935516, abs=1e-4 + ) + + async def test_media_player_alt( hass: HomeAssistant, snapshot: SnapshotAssertion, From 3040fdf6c5363e4889bf6ab73cd27107be99c32b Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Jul 2026 10:00:51 +0200 Subject: [PATCH 141/707] Add MIT-0 to OSI approved SPDX licenses (#175819) --- script/licenses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/licenses.py b/script/licenses.py index 38a14d711c57..01fdf114e5b8 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -83,6 +83,7 @@ OSI_APPROVED_LICENSES_SPDX = { "LGPL-3.0-only", "LGPL-3.0-or-later", "MIT", + "MIT-0", "MIT-CMU", "MPL-1.1", "MPL-2.0", From 55988ba4d77838be647cd8e020c3f223b3cc4000 Mon Sep 17 00:00:00 2001 From: Keith Buck Date: Tue, 7 Jul 2026 01:03:15 -0700 Subject: [PATCH 142/707] Proximity: Rename incorrect uses of 'device' to 'tracker'. (#175649) --- .../components/proximity/coordinator.py | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 6be9b1db7156..cf9e75ea8c2c 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -143,7 +143,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): }, ) - def _device_in_zone(self, zone: State, device: State) -> bool: + def _tracked_entity_in_zone(self, zone: State, tracked_entity_state: State) -> bool: """Return whether the tracked entity is currently in the proximity zone.""" # Modern entity-based trackers and person entities always report zone @@ -160,13 +160,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): # (deprecated, removed in HA Core 2027.7). # For both, an empty or absent ``in_zones`` does not imply "in no zone", so we - # fall back to matching the device state against the zone's friendly name - # (what a tracker's state is set to for non-home zones), plus an explicit + # fall back to matching the tracked entity state against the zone's friendly name + # (what a tracked entity's state is set to for non-home zones), plus an explicit # home-zone check. Once both deprecations are gone, ``in_zones`` is - # authoritative for every tracker and this method should reduce to the + # authoritative for every tracked entity and this method should reduce to the # membership check alone; the fallback must be removed, as second-guessing an # empty list would then be incorrect. - if in_zones := device.attributes.get(ATTR_IN_ZONES): + if in_zones := tracked_entity_state.attributes.get(ATTR_IN_ZONES): return zone.entity_id in in_zones # Remove once legacy device trackers (2027.5) and location_name (2027.7) @@ -174,21 +174,24 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): zone_friendly_name = zone.attributes.get(ATTR_FRIENDLY_NAME) return ( zone_friendly_name is not None - and device.state.lower() == zone_friendly_name.lower() - ) or (device.state == STATE_HOME and zone.entity_id == ENTITY_ID_HOME) + and tracked_entity_state.state.lower() == zone_friendly_name.lower() + ) or ( + tracked_entity_state.state == STATE_HOME + and zone.entity_id == ENTITY_ID_HOME + ) def _calc_distance_to_zone( self, zone: State, - device: State, + tracked_entity_state: State, latitude: float | None, longitude: float | None, ) -> int | None: - if self._device_in_zone(zone, device): + if self._tracked_entity_in_zone(zone, tracked_entity_state): _LOGGER.debug( "%s: %s in zone -> distance=0", self.name, - device.entity_id, + tracked_entity_state.entity_id, ) return 0 @@ -196,7 +199,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): _LOGGER.debug( "%s: %s has no coordinates -> distance=None", self.name, - device.entity_id, + tracked_entity_state.entity_id, ) return None @@ -220,17 +223,17 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): def _calc_direction_of_travel( self, zone: State, - device: State, + tracked_entity_state: State, old_latitude: float | None, old_longitude: float | None, new_latitude: float | None, new_longitude: float | None, ) -> str | None: - if self._device_in_zone(zone, device): + if self._tracked_entity_in_zone(zone, tracked_entity_state): _LOGGER.debug( "%s: %s in zone -> direction_of_travel=arrived", self.name, - device.entity_id, + tracked_entity_state.entity_id, ) return "arrived" From 14d12155d12e985fdfde15f11f3a6da17499711a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 7 Jul 2026 10:06:49 +0200 Subject: [PATCH 143/707] Bump holidays to 0.100 (#175809) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 0edf787d5d05..43038c1a8c41 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.99", "babel==2.18.0"] + "requirements": ["holidays==0.100", "babel==2.18.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index b4df67d61b7c..1d2009138865 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.99"] + "requirements": ["holidays==0.100"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba57845761fd..b5f0047893ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1263,7 +1263,7 @@ hole==0.9.2 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.99 +holidays==0.100 # homeassistant.components.frontend home-assistant-frontend==20260624.4 From b0e05f795d95c9e47598f84052e81bd60cea98e7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:22:49 +0200 Subject: [PATCH 144/707] Reproduce only value in counter reproduce_state (#175780) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/counter/reproduce_state.py | 18 ++-------- .../counter/test_reproduce_state.py | 36 ++++++------------- 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py index 30d390e5588c..5423da44e58e 100644 --- a/homeassistant/components/counter/reproduce_state.py +++ b/homeassistant/components/counter/reproduce_state.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, HomeAssistant, State -from . import ATTR_MAXIMUM, ATTR_MINIMUM, ATTR_STEP, DOMAIN, SERVICE_SET_VALUE, VALUE +from . import DOMAIN, SERVICE_SET_VALUE, VALUE _LOGGER = logging.getLogger(__name__) @@ -32,25 +32,13 @@ async def _async_reproduce_state( return # Return if we are already at the right state. - if ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_MAXIMUM) == state.attributes.get(ATTR_MAXIMUM) - and cur_state.attributes.get(ATTR_MINIMUM) == state.attributes.get(ATTR_MINIMUM) - and cur_state.attributes.get(ATTR_STEP) == state.attributes.get(ATTR_STEP) - ): + if cur_state.state == state.state: return service_data = {ATTR_ENTITY_ID: state.entity_id, VALUE: state.state} - service = SERVICE_SET_VALUE - if ATTR_MAXIMUM in state.attributes: - service_data[ATTR_MAXIMUM] = state.attributes[ATTR_MAXIMUM] - if ATTR_MINIMUM in state.attributes: - service_data[ATTR_MINIMUM] = state.attributes[ATTR_MINIMUM] - if ATTR_STEP in state.attributes: - service_data[ATTR_STEP] = state.attributes[ATTR_STEP] await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True + DOMAIN, SERVICE_SET_VALUE, service_data, context=context, blocking=True ) diff --git a/tests/components/counter/test_reproduce_state.py b/tests/components/counter/test_reproduce_state.py index 6b985b5a6872..7cd959dcf111 100644 --- a/tests/components/counter/test_reproduce_state.py +++ b/tests/components/counter/test_reproduce_state.py @@ -15,44 +15,34 @@ async def test_reproducing_states( """Test reproducing Counter states.""" hass.states.async_set("counter.entity", "5", {}) hass.states.async_set( - "counter.entity_attr", - "8", - {"minimum": 5, "maximum": 15, "step": 3}, + "counter.entity_attr", "8", {"minimum": 5, "maximum": 15, "step": 3} ) - configure_calls = async_mock_service(hass, DOMAIN, "set_value") + set_value_calls = async_mock_service(hass, DOMAIN, "set_value") - # These calls should do nothing as entities already in desired state + # These calls should do nothing as entity already in desired state await async_reproduce_state( hass, [ State("counter.entity", "5"), - State( - "counter.entity_attr", - "8", - {"minimum": 5, "maximum": 15, "step": 3}, - ), + State("counter.entity_attr", "8"), ], ) - assert len(configure_calls) == 0 + assert len(set_value_calls) == 0 # Test invalid state is handled await async_reproduce_state(hass, [State("counter.entity", "not_supported")]) assert "not_supported" in caplog.text - assert len(configure_calls) == 0 + assert len(set_value_calls) == 0 # Make sure correct services are called await async_reproduce_state( hass, [ State("counter.entity", "2"), - State( - "counter.entity_attr", - "7", - {"minimum": 3, "maximum": 21, "step": 5}, - ), + State("counter.entity_attr", "7", {"minimum": 3, "maximum": 21, "step": 5}), # Should not raise State("counter.non_existing", "6"), ], @@ -60,16 +50,10 @@ async def test_reproducing_states( valid_calls = [ {"entity_id": "counter.entity", "value": "2"}, - { - "entity_id": "counter.entity_attr", - "value": "7", - "minimum": 3, - "maximum": 21, - "step": 5, - }, + {"entity_id": "counter.entity_attr", "value": "7"}, ] - assert len(configure_calls) == 2 - for call in configure_calls: + assert len(set_value_calls) == 2 + for call in set_value_calls: assert call.domain == "counter" assert call.data in valid_calls valid_calls.remove(call.data) From a94d42fbbb359491e719ca0bf13f5544dcf26a2b Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Jul 2026 10:27:18 +0200 Subject: [PATCH 145/707] Bump pyoverkiz to 2.0.4 (#175818) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index a4167e6a9f5d..4b843588c16d 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -14,7 +14,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz[nexity]==2.0.3"], + "requirements": ["pyoverkiz[nexity]==2.0.4"], "zeroconf": [ { "name": "gateway*", diff --git a/requirements_all.txt b/requirements_all.txt index b5f0047893ff..a6d03887f8f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2436,7 +2436,7 @@ pyotgw==2.2.3 pyotp==2.9.0 # homeassistant.components.overkiz -pyoverkiz[nexity]==2.0.3 +pyoverkiz[nexity]==2.0.4 # homeassistant.components.palazzetti pypalazzetti==0.1.20 From 1d4cfba056c4d3109d7ce063353c1deba5a3822e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 7 Jul 2026 18:45:13 +1000 Subject: [PATCH 146/707] Bump tesla-fleet-api to 1.5.4 (#175827) Co-authored-by: Mick Vleeshouwer --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 6fdf4a6b6b7f..cc1325780b1a 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.5.2"] + "requirements": ["tesla-fleet-api==1.5.4"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 18028f5f5ac7..269c9ceef2cc 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.5.2", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.5.4", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 7d653e6d4019..2be7f21d458e 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.5.2"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6d03887f8f7..1b1f3248c6fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3147,7 +3147,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.5.2 +tesla-fleet-api==1.5.4 # homeassistant.components.powerwall tesla-powerwall==0.5.3 From 18fe2e754d9f9029a4b6ae2e27c35ad0c35b31c3 Mon Sep 17 00:00:00 2001 From: Mattie <6250046+MattieGit@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:52:09 +0200 Subject: [PATCH 147/707] Bump python-qube-heatpump to 1.12.0 (#175834) --- homeassistant/components/hr_energy_qube/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hr_energy_qube/manifest.json b/homeassistant/components/hr_energy_qube/manifest.json index 5992fbed0793..0fe661bdbbe1 100644 --- a/homeassistant/components/hr_energy_qube/manifest.json +++ b/homeassistant/components/hr_energy_qube/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["python_qube_heatpump"], "quality_scale": "bronze", - "requirements": ["python-qube-heatpump==1.11.0"] + "requirements": ["python-qube-heatpump==1.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b1f3248c6fc..e33b88555651 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2716,7 +2716,7 @@ python-picnic-api2==1.3.4 python-pooldose==0.9.6 # homeassistant.components.hr_energy_qube -python-qube-heatpump==1.11.0 +python-qube-heatpump==1.12.0 # homeassistant.components.rabbitair python-rabbitair==0.0.8 From a01520d99967b1ec5d5db6d954e01a85831905bf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:33:03 +0200 Subject: [PATCH 148/707] Use EntityStateAttribute enum in assist_pipeline (#175839) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/assist_pipeline/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 258d29fceeb4..c7932f7adee9 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -25,7 +25,7 @@ from homeassistant.components import ( wake_word, websocket_api, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL +from homeassistant.const import MATCH_ALL, EntityStateAttribute from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -1255,7 +1255,7 @@ class PipelineRun: if ( intent_agent_state := self.hass.states.get(self.intent_agent.id) ) and intent_agent_state.attributes.get( - ATTR_SUPPORTED_FEATURES, 0 + EntityStateAttribute.SUPPORTED_FEATURES, 0 ) & conversation.ConversationEntityFeature.CONTROL: intent_filter = _async_local_fallback_intent_filter From ed557266934be8ba223ea8bc12bfb537181bc385 Mon Sep 17 00:00:00 2001 From: nate-in-hutch Date: Tue, 7 Jul 2026 06:08:50 -0400 Subject: [PATCH 149/707] Use core radon device class for Airthings cloud radon (#166087) --- homeassistant/components/airthings/sensor.py | 23 +++++-------------- .../components/airthings/strings.json | 3 --- .../airthings/snapshots/test_sensor.ambr | 18 ++++++++------- 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index ecfcd026b665..1188cfd443bd 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( EntityCategory, UnitOfDensity, UnitOfPressure, + UnitOfRadiationConcentration, UnitOfRatio, UnitOfSoundPressure, UnitOfTemperature, @@ -33,36 +34,34 @@ from .coordinator import AirthingsDataUpdateCoordinator SENSORS: dict[str, SensorEntityDescription] = { "radonShortTermAvg": SensorEntityDescription( key="radonShortTermAvg", - native_unit_of_measurement="Bq/m³", - translation_key="radon", + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=( + UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER + ), suggested_display_precision=0, ), "temp": SensorEntityDescription( key="temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=UnitOfRatio.PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), "sla": SensorEntityDescription( key="sla", device_class=SensorDeviceClass.SOUND_PRESSURE, native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "battery": SensorEntityDescription( @@ -70,47 +69,40 @@ SENSORS: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=UnitOfRatio.PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "voc": SensorEntityDescription( key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=UnitOfRatio.PARTS_PER_BILLION, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "light": SensorEntityDescription( key="light", native_unit_of_measurement=UnitOfRatio.PERCENTAGE, translation_key="light", - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "lux": SensorEntityDescription( key="lux", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "virusRisk": SensorEntityDescription( key="virusRisk", translation_key="virus_risk", - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "mold": SensorEntityDescription( key="mold", translation_key="mold", - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "rssi": SensorEntityDescription( @@ -119,21 +111,18 @@ SENSORS: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "pm1": SensorEntityDescription( key="pm1", native_unit_of_measurement=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "pm25": SensorEntityDescription( key="pm25", native_unit_of_measurement=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), } @@ -145,8 +134,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airthings sensor.""" - coordinator = entry.runtime_data + entities = [ AirthingsDeviceSensor( coordinator, diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json index 5e6d58addbe5..3c3e5dfc636d 100644 --- a/homeassistant/components/airthings/strings.json +++ b/homeassistant/components/airthings/strings.json @@ -26,9 +26,6 @@ "mold": { "name": "Mold" }, - "radon": { - "name": "Radon" - }, "virus_risk": { "name": "Virus Risk" } diff --git a/tests/components/airthings/snapshots/test_sensor.ambr b/tests/components/airthings/snapshots/test_sensor.ambr index b6764ea68b75..c69c031c1f60 100644 --- a/tests/components/airthings/snapshots/test_sensor.ambr +++ b/tests/components/airthings/snapshots/test_sensor.ambr @@ -380,24 +380,25 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Radon', 'platform': 'airthings', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'radon', + 'translation_key': None, 'unique_id': '2960000001_radonShortTermAvg', - 'unit_of_measurement': 'Bq/m³', + 'unit_of_measurement': , }) # --- # name: test_all_device_types[view_plus][sensor.living_room_radon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'radon', : 'Living Room Radon', : , - : 'Bq/m³', + : , }), 'context': , 'entity_id': 'sensor.living_room_radon', @@ -1255,24 +1256,25 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Radon', 'platform': 'airthings', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'radon', + 'translation_key': None, 'unique_id': '2930000002_radonShortTermAvg', - 'unit_of_measurement': 'Bq/m³', + 'unit_of_measurement': , }) # --- # name: test_all_device_types[wave_plus][sensor.office_radon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'radon', : 'Office Radon', : , - : 'Bq/m³', + : , }), 'context': , 'entity_id': 'sensor.office_radon', From 6e428e6a2f5bed627834a921a74079b70fbe34ed Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Jul 2026 12:18:23 +0200 Subject: [PATCH 150/707] Fix RollerShutterUno covers reporting open when closed in Overkiz (#175837) --- homeassistant/components/overkiz/cover.py | 11 +++++++++++ .../setup/local_somfy_tahoma_switch_europe_2.json | 2 +- tests/components/overkiz/snapshots/test_cover.ambr | 6 +++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 818baaada0fb..25a872a00e90 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -88,6 +88,17 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [ invert_position=False, is_closed_state=OverkizState.CORE_OPEN_CLOSED, ), + # Needs override to omit is_closed_state, since OpenClosedState is unreliable + # uiClass is RollerShutter + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_ROLLER_SHUTTER_UNO, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + ), # Needs override to support lower/upper position control # uiClass is RollerShutter OverkizCoverDescription( diff --git a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json index 96c5eec23791..5ba8b46a82b4 100644 --- a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json +++ b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json @@ -1647,7 +1647,7 @@ { "type": 1, "name": "core:TargetClosureState", - "value": 0 + "value": 100 }, { "type": 6, diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index a7bdf6b33a2b..e8b5e5d604a0 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -3339,10 +3339,10 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.back_door_shutter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 100, + : 0, : 'shutter', : 'Back Door Shutter', - : False, + : True, : , }), 'context': , @@ -3350,7 +3350,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'closed', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.front_door_shutter-entry] From db0d5da9b30eb4b22793613428b35d96862b44af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Tue, 7 Jul 2026 12:49:58 +0200 Subject: [PATCH 151/707] Bump pywmspro to 0.4.2 (#175805) Co-authored-by: Mick Vleeshouwer --- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index bcdc71e2b336..dbd7b0cae917 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.4.0"] + "requirements": ["pywmspro==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e33b88555651..774365cb7fa6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2831,7 +2831,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.4.0 +pywmspro==0.4.2 # homeassistant.components.ws66i pyws66i==1.1 From a3c58649d84ed95b68924644ef187ddc1017ee53 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:32:55 +0200 Subject: [PATCH 152/707] Use EntityStateAttribute enum in helpers (#175836) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/helpers/area_registry.py | 8 +++++--- homeassistant/helpers/condition.py | 15 +++++++-------- homeassistant/helpers/intent.py | 14 ++++++-------- homeassistant/helpers/llm.py | 6 +++++- homeassistant/helpers/template/states.py | 10 ++++++---- homeassistant/helpers/trigger.py | 14 ++++++++------ homeassistant/helpers/trigger_template_entity.py | 10 ++++------ 7 files changed, 41 insertions(+), 36 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index c775a4aed728..8ab868a35b5e 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, Any, Literal, TypedDict, override -from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType @@ -581,7 +581,8 @@ def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: if ( state.domain != "sensor" - or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE + or state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + != SensorDeviceClass.TEMPERATURE ): raise ValueError(f"Entity {entity_id} is not a temperature sensor") @@ -595,6 +596,7 @@ def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: if ( state.domain != "sensor" - or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY + or state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + != SensorDeviceClass.HUMIDITY ): raise ValueError(f"Entity {entity_id} is not a humidity sensor") diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 212ecf02f380..7e057921f616 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -31,8 +31,6 @@ from typing import ( import voluptuous as vol from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_ABOVE, CONF_AFTER, CONF_ATTRIBUTE, @@ -56,6 +54,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, WEEKDAYS, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.exceptions import ( @@ -989,7 +988,7 @@ class EntityNumericalConditionBase(EntityConditionBase): # Entity not found return None if not self._is_valid_unit( - entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) ): # Entity unit does not match the expected unit return None @@ -1007,7 +1006,7 @@ class EntityNumericalConditionBase(EntityConditionBase): domain_spec = self._domain_specs[entity_state.domain] if domain_spec.value_source is None: if not self._is_valid_unit( - entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) ): return None return entity_state.state @@ -1095,7 +1094,7 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): def _get_entity_unit(self, entity_state: State) -> str | None: """Get the unit of an entity from its state.""" - return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) @override def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: @@ -1121,7 +1120,7 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): try: return self._unit_converter.convert( value, - entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT), self._base_unit, ) except HomeAssistantError: @@ -1851,7 +1850,7 @@ def time( ): after = datetime.strptime(after_entity.state, "%H:%M:%S").time() elif ( - after_entity.attributes.get(ATTR_DEVICE_CLASS) + after_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and after_entity.state not in ( STATE_UNAVAILABLE, @@ -1881,7 +1880,7 @@ def time( except ValueError: return False elif ( - before_entity.attributes.get(ATTR_DEVICE_CLASS) + before_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and before_entity.state not in ( STATE_UNAVAILABLE, diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f65c731d12a0..1a8528c8f039 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -14,11 +14,7 @@ from propcache.api import cached_property import voluptuous as vol from homeassistant.components.homeassistant.exposed_entities import async_should_expose -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, -) +from homeassistant.const import ATTR_ENTITY_ID, EntityStateAttribute from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.hass_dict import HassKey @@ -455,7 +451,9 @@ def _filter_by_features( yield candidate continue - supported_features = candidate.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported_features = candidate.state.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if (supported_features & features) == features: yield candidate @@ -474,7 +472,7 @@ def _filter_by_device_classes( yield candidate continue - device_class = candidate.state.attributes.get(ATTR_DEVICE_CLASS) + device_class = candidate.state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class and (device_class in device_classes): yield candidate @@ -811,7 +809,7 @@ def async_match_states( @callback def async_test_feature(state: State, feature: int, feature_name: str) -> None: """Test if state supports a feature.""" - if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0: + if state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) & feature == 0: raise IntentHandleError(f"Entity {state.name} does not support {feature_name}") diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 64ea03a869f1..1b5f509b6612 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -30,6 +30,7 @@ from homeassistant.const import ( ATTR_SERVICE, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, + EntityStateAttribute, ) from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError @@ -731,7 +732,10 @@ def _get_exposed_entities( info["state"] = async_rounded_state(hass, state.entity_id, state) # Convert timestamp device_class states from UTC to local time - if state.attributes.get("device_class") == "timestamp" and state.state: + if ( + state.attributes.get(EntityStateAttribute.DEVICE_CLASS) == "timestamp" + and state.state + ): if (parsed_utc := dt_util.parse_datetime(state.state)) is not None: info["state"] = dt_util.as_local(parsed_utc).isoformat() diff --git a/homeassistant/helpers/template/states.py b/homeassistant/helpers/template/states.py index af8abdf608a7..4563f8d2a4d0 100644 --- a/homeassistant/helpers/template/states.py +++ b/homeassistant/helpers/template/states.py @@ -9,7 +9,7 @@ from typing import Any, override from lru import LRU from propcache.api import under_cached_property -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, EntityStateAttribute from homeassistant.core import ( Context, HomeAssistant, @@ -182,7 +182,7 @@ class StateTranslated: state_value = state.state domain = state.domain - device_class = state.attributes.get("device_class") + device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) entry = er.async_get(self._hass).async_get(entity_id) platform = None if entry is None else entry.platform translation_key = None if entry is None else entry.translation_key @@ -219,7 +219,7 @@ class StateAttrTranslated: return attr_value domain = state.domain - device_class = state.attributes.get("device_class") + device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) entry = er.async_get(self._hass).async_get(entity_id) platform = None if entry is None else entry.platform translation_key = None if entry is None else entry.translation_key @@ -413,7 +413,9 @@ class TemplateStateBase(State): state = async_rounded_state(self._hass, self._entity_id, self._state) else: state = self._state.state - if with_unit and (unit := self._state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + if with_unit and ( + unit := self._state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ): return f"{state} {unit}" return state diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index a541242de992..b993b07bff4b 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -26,7 +26,6 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_UNIT_OF_MEASUREMENT, CONF_ALIAS, CONF_DEVICE_ID, CONF_ENABLED, @@ -42,6 +41,7 @@ from homeassistant.const import ( CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -856,7 +856,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): entity_id=threshold.entity, ) return None - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if not self._is_valid_unit(unit): # Entity unit does not match the expected unit report_not_triggered( @@ -882,7 +882,9 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): domain_spec = self._domain_specs[state.domain] raw_value: Any if domain_spec.value_source is None: - if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + if not self._is_valid_unit( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ): return None raw_value = state.state else: @@ -907,7 +909,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): domain_spec = self._domain_specs[state.domain] raw_value: Any if domain_spec.value_source is None: - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if not self._is_valid_unit(unit): report_not_triggered( "entity_unit_not_supported", @@ -984,7 +986,7 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): def _get_entity_unit(self, state: State) -> str | None: """Get the unit of an entity from its state.""" - return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) @override def _report_tracked_value_problem( @@ -1050,7 +1052,7 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): ) return None - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) try: return self._unit_converter.convert(value, unit, self._base_unit) except HomeAssistantError: diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index e5ad9964ea27..9515fd43f85d 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -18,14 +18,12 @@ from homeassistant.components.sensor.helpers import ( # pylint: disable=home-as async_parse_date_datetime, ) from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, CONF_DEVICE_CLASS, CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError @@ -48,9 +46,9 @@ CONF_ATTRIBUTES = "attributes" CONF_PICTURE = "picture" CONF_TO_ATTRIBUTE = { - CONF_ICON: ATTR_ICON, - CONF_NAME: ATTR_FRIENDLY_NAME, - CONF_PICTURE: ATTR_ENTITY_PICTURE, + CONF_ICON: EntityStateAttribute.ICON, + CONF_NAME: EntityStateAttribute.FRIENDLY_NAME, + CONF_PICTURE: EntityStateAttribute.ENTITY_PICTURE, } TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( From eba1aa7d2c4d97458314156b8d5064d9a2671bc9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:33:24 +0200 Subject: [PATCH 153/707] Use EventEntityStateAttribute enum in doorbell (#175849) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/doorbell/trigger.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/doorbell/trigger.py b/homeassistant/components/doorbell/trigger.py index 6d68ddb8889b..6e54e1ad8ba1 100644 --- a/homeassistant/components/doorbell/trigger.py +++ b/homeassistant/components/doorbell/trigger.py @@ -3,10 +3,10 @@ from typing import override from homeassistant.components.event import ( - ATTR_EVENT_TYPE, DOMAIN as EVENT_DOMAIN, DoorbellEventType, EventDeviceClass, + EventEntityStateAttribute, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec @@ -29,7 +29,10 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the event type is ring.""" - return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING + return ( + state.attributes.get(EventEntityStateAttribute.EVENT_TYPE) + == DoorbellEventType.RING + ) TRIGGERS: dict[str, type[Trigger]] = { From 86831403486109a9fd45b1f0304706391d0d0ca7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:35:37 +0200 Subject: [PATCH 154/707] Update infrared-protocols to 6.5.0 (#175825) --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index c8d05986266a..319e3c63d804 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==6.3.1"] + "requirements": ["infrared-protocols==6.5.0"] } diff --git a/requirements.txt b/requirements.txt index 4cc45c51c995..fd856a0670e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==6.3.1 +infrared-protocols==6.5.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index 774365cb7fa6..474f943aab37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1359,7 +1359,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==6.3.1 +infrared-protocols==6.5.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 From 65edb1cfb2b68bb06cbcdd9daa279370eef1a200 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:37:46 +0200 Subject: [PATCH 155/707] Use state attribute enums in legacy device_tracker (#175854) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/device_tracker/legacy.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5fedb6686515..8abb4a44f3d3 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -77,7 +77,9 @@ from .const import ( LOGGER, PLATFORM_TYPE_LEGACY, SCAN_INTERVAL, + DeviceTrackerEntityStateAttribute, SourceType, + TrackerEntityStateAttribute, ) _LOGGER = logging.getLogger(__name__) @@ -840,12 +842,14 @@ class Device(RestoreEntity): @override def state_attributes(self) -> dict[str, StateType]: """Return the device state attributes.""" - attributes: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type} + attributes: dict[str, StateType] = { + DeviceTrackerEntityStateAttribute.SOURCE_TYPE: self.source_type + } if self.gps is not None: - attributes[ATTR_LATITUDE] = self.gps[0] - attributes[ATTR_LONGITUDE] = self.gps[1] - attributes[ATTR_GPS_ACCURACY] = self.gps_accuracy + attributes[TrackerEntityStateAttribute.LATITUDE] = self.gps[0] + attributes[TrackerEntityStateAttribute.LONGITUDE] = self.gps[1] + attributes[TrackerEntityStateAttribute.GPS_ACCURACY] = self.gps_accuracy if self.battery is not None: attributes[ATTR_BATTERY] = self.battery @@ -952,17 +956,17 @@ class Device(RestoreEntity): self.last_seen = dt_util.utcnow() for attribute, var in ( - (ATTR_SOURCE_TYPE, "source_type"), - (ATTR_GPS_ACCURACY, "gps_accuracy"), + (DeviceTrackerEntityStateAttribute.SOURCE_TYPE, "source_type"), + (TrackerEntityStateAttribute.GPS_ACCURACY, "gps_accuracy"), (ATTR_BATTERY, "battery"), ): if attribute in state.attributes: setattr(self, var, state.attributes[attribute]) - if ATTR_LONGITUDE in state.attributes: + if TrackerEntityStateAttribute.LONGITUDE in state.attributes: self.gps = ( - state.attributes[ATTR_LATITUDE], - state.attributes[ATTR_LONGITUDE], + state.attributes[TrackerEntityStateAttribute.LATITUDE], + state.attributes[TrackerEntityStateAttribute.LONGITUDE], ) From b66961df3ffcf8ab5acfa784cfead6fd1085a7d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:53:31 +0200 Subject: [PATCH 156/707] Use EntityStateAttribute enum in compensation (#175845) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/compensation/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 0f12c8856d30..ef78235ef704 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -6,14 +6,12 @@ from typing import Any, override import numpy as np from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, SensorEntity, + SensorEntityCapabilityAttribute, ) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, CONF_DEVICE_CLASS, CONF_MAXIMUM, @@ -24,6 +22,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -172,16 +171,18 @@ class CompensationSensor(SensorEntity): if self.native_unit_of_measurement is None and self._source_attribute is None: self._attr_native_unit_of_measurement = new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) if self._attr_device_class is None and ( - device_class := new_state.attributes.get(ATTR_DEVICE_CLASS) + device_class := new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS) ): self._attr_device_class = device_class if self._attr_state_class is None and ( - state_class := new_state.attributes.get(ATTR_STATE_CLASS) + state_class := new_state.attributes.get( + SensorEntityCapabilityAttribute.STATE_CLASS + ) ): self._attr_state_class = state_class From e7c44950c9514129261282cc6a6172dd963bb67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 7 Jul 2026 12:58:47 +0100 Subject: [PATCH 157/707] Add copilot instruction to check the PR template (#175767) --- .github/copilot-instructions.md | 1 + script/gen_copilot_instructions.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 30e3c670c6ae..523ac8485fcc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,6 +8,7 @@ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. +- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present and filled in, except the `## Breaking change` section which is optional. # GitHub Copilot & Claude Code Instructions diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index 3e118479b69b..4c8894b95ad5 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -25,6 +25,7 @@ COPILOT_SPECIFIC_INSTRUCTIONS = """ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. +- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present and filled in, except the `## Breaking change` section which is optional. """ INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """--- From 11d4475c1fc033e0fe02ab58225a53c6295559c7 Mon Sep 17 00:00:00 2001 From: TimL Date: Tue, 7 Jul 2026 22:08:05 +1000 Subject: [PATCH 158/707] Drop infrared translation keys from SMLight (#175821) --- homeassistant/components/smlight/infrared.py | 2 -- homeassistant/components/smlight/strings.json | 5 ----- tests/components/smlight/test_infrared.py | 12 ++++++------ 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py index 02de21ab7825..7567ba60d116 100644 --- a/homeassistant/components/smlight/infrared.py +++ b/homeassistant/components/smlight/infrared.py @@ -43,8 +43,6 @@ async def async_setup_entry( class SmInfraredEntity(SmEntity, InfraredEmitterEntity): """Representation of a SLZB-Ultima infrared emitter.""" - _attr_translation_key = "infrared_emitter" - def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: """Initialize the SLZB-Ultima infrared.""" super().__init__(coordinator) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ad83fc40d7e1..b56674ee2978 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -91,11 +91,6 @@ "name": "Zigbee restart" } }, - "infrared": { - "infrared_emitter": { - "name": "IR emitter" - } - }, "light": { "ambilight": { "name": "Ambilight" diff --git a/tests/components/smlight/test_infrared.py b/tests/components/smlight/test_infrared.py index ac4ec0d0c330..012cae971033 100644 --- a/tests/components/smlight/test_infrared.py +++ b/tests/components/smlight/test_infrared.py @@ -48,7 +48,7 @@ async def test_infrared_setup_ultima( """Test infrared entities are created for Ultima devices.""" await setup_integration(hass, mock_config_entry) - state = hass.states.get("infrared.mock_title_ir_emitter") + state = hass.states.get("infrared.mock_title_infrared_emitter") assert state is not None state = hass.states.get("infrared.mock_title_infrared_receiver") @@ -63,7 +63,7 @@ async def test_infrared_not_created_non_ultima( """Test infrared entities are not created for non-Ultima devices.""" await setup_integration(hass, mock_config_entry) - state = hass.states.get("infrared.mock_title_ir_emitter") + state = hass.states.get("infrared.mock_title_infrared_emitter") assert state is None state = hass.states.get("infrared.mock_title_infrared_receiver") @@ -78,7 +78,7 @@ async def test_infrared_send_command( """Test sending IR command.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None @@ -101,7 +101,7 @@ async def test_infrared_send_command_error( """Test connection error handling.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None @@ -124,7 +124,7 @@ async def test_infrared_send_empty_command_error( """Test ValueError from pysmlight is surfaced as HomeAssistantError.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None @@ -148,7 +148,7 @@ async def test_infrared_state_updated_after_send( """Test that entity state is updated with a timestamp after a successful send.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNKNOWN From aafd8b4f7ca0b06b3280faccb4235830ada5c4ce Mon Sep 17 00:00:00 2001 From: TimL Date: Tue, 7 Jul 2026 22:10:08 +1000 Subject: [PATCH 159/707] Add options flow for configuring SMLIGHT BLE scanner (#175826) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/smlight/__init__.py | 16 +- homeassistant/components/smlight/bluetooth.py | 67 ++++++- .../components/smlight/config_flow.py | 113 ++++++++++- homeassistant/components/smlight/const.py | 12 ++ homeassistant/components/smlight/strings.json | 30 +++ tests/components/smlight/conftest.py | 1 + tests/components/smlight/test_bluetooth.py | 109 +++++++++++ tests/components/smlight/test_config_flow.py | 184 ++++++++++++++++++ 8 files changed, 517 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 76db28b72bc0..b233cf39ce92 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -4,18 +4,17 @@ from pysmlight import Api2 from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .bluetooth import async_connect_scanner +from .bluetooth import async_setup_ble_scanner from .const import DOMAIN from .coordinator import ( SmConfigEntry, SmDataUpdateCoordinator, SmFirmwareUpdateCoordinator, SmlightData, - base_device_info, ) from .services import async_setup_services @@ -55,13 +54,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: hass, client.sse.client(), "smlight-sse-client" ) - if info.ble is not None and info.ble.proxy_enabled: - device_registry = dr.async_get(hass) - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - **base_device_info(info, client.host), - ) - entry.async_on_unload(async_connect_scanner(hass, entry, info.model, device.id)) + if info.ble is not None and ( + unload_callback := await async_setup_ble_scanner(hass, entry, client, info) + ): + entry.async_on_unload(unload_callback) entry.runtime_data = SmlightData( data=data_coordinator, diff --git a/homeassistant/components/smlight/bluetooth.py b/homeassistant/components/smlight/bluetooth.py index a6c7647ca99b..f3e0d52de208 100644 --- a/homeassistant/components/smlight/bluetooth.py +++ b/homeassistant/components/smlight/bluetooth.py @@ -1,9 +1,10 @@ """Bluetooth proxy for SLZB devices using bleak-smlight.""" from functools import partial +import logging from bleak_smlight import SLZB_BLE_SERVER_PORT, connect_scanner -from pysmlight import BleProxyClient +from pysmlight import Api2, BleProxyClient, Info from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -11,9 +12,12 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr -from .const import DOMAIN -from .coordinator import SmConfigEntry +from .const import CONF_BLE_SCANNER_MODE, DOMAIN, BLEScannerMode +from .coordinator import SmConfigEntry, base_device_info + +_LOGGER = logging.getLogger(__name__) @callback @@ -33,6 +37,7 @@ def async_connect_scanner( entry: SmConfigEntry, model: str | None, device_id: str, + scanner_mode: BluetoothScanningMode = BluetoothScanningMode.AUTO, ) -> CALLBACK_TYPE: """Connect scanner using the external bleak-smlight backend.""" assert entry.unique_id is not None @@ -44,7 +49,7 @@ def async_connect_scanner( port=SLZB_BLE_SERVER_PORT, ) - client_data.scanner.async_set_scanning_mode(BluetoothScanningMode.AUTO) + client_data.scanner.async_set_scanning_mode(scanner_mode) entry.async_create_background_task( hass, @@ -65,3 +70,57 @@ def async_connect_scanner( ] return partial(_async_unload, unload_callbacks, client_data.client) + + +async def async_setup_ble_scanner( + hass: HomeAssistant, + entry: SmConfigEntry, + client: Api2, + info: Info, +) -> CALLBACK_TYPE | None: + """Set up the BLE scanner/proxy configuration.""" + assert info.ble is not None + + scanner_mode = get_ble_scanner_mode(entry, info) + + remote_adapter_enabled = scanner_mode != BLEScannerMode.DISABLED + + if remote_adapter_enabled: + if not info.ble.proxy_enabled: + _LOGGER.warning( + "SMLIGHT BLE proxy is enabled in Home Assistant options but disabled on the device. " + "Please reconfigure the integration options to align settings" + ) + return None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + **base_device_info(info, client.host), + ) + return async_connect_scanner( + hass, + entry, + info.model, + device.id, + BluetoothScanningMode(scanner_mode), + ) + + return None + + +@callback +def get_ble_scanner_mode( + entry: SmConfigEntry, + info: Info, +) -> BLEScannerMode: + """Get the BLE scanner mode config or default.""" + if info.ble is None: + return BLEScannerMode.DISABLED + + return BLEScannerMode( + entry.options.get( + CONF_BLE_SCANNER_MODE, + BLEScannerMode.AUTO if info.ble.proxy_enabled else BLEScannerMode.DISABLED, + ) + ) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index ce081b35ff43..00cda638dbd5 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -13,14 +13,23 @@ from homeassistant.config_entries import ( SOURCE_USER, ConfigFlow, ConfigFlowResult, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import DOMAIN +from .bluetooth import get_ble_scanner_mode +from .const import CONF_BLE_SCANNER_MODE, DOMAIN, BLEScannerMode +from .coordinator import SmConfigEntry STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -35,6 +44,25 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( } ) +BLE_SCANNER_OPTIONS = [ + BLEScannerMode.DISABLED, + BLEScannerMode.AUTO, + BLEScannerMode.ACTIVE, + BLEScannerMode.PASSIVE, +] + +BLE_SCANNER_SCHEMA = vol.Schema( + { + vol.Required(CONF_BLE_SCANNER_MODE): SelectSelector( + SelectSelectorConfig( + options=BLE_SCANNER_OPTIONS, + translation_key=CONF_BLE_SCANNER_MODE, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } +) + class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" @@ -275,3 +303,86 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): assert info.model is not None title = self._device_name or info.model return self.async_create_entry(title=title, data=user_input) + + @staticmethod + @callback + @override + def async_get_options_flow( + config_entry: SmConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(OptionsFlowWithReload): + """Handle options flow for SMLIGHT.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + errors: dict[str, str] = {} + + if not hasattr(self.config_entry, "runtime_data"): + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + BLE_SCANNER_SCHEMA, + user_input or {CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED}, + ), + errors=errors, + ) + + coordinator = self.config_entry.runtime_data.data + info = coordinator.data.info + + if info.ble is None: + return await self.async_step_no_settings() + + if user_input is not None: + scanner_mode = BLEScannerMode(user_input[CONF_BLE_SCANNER_MODE]) + user_input[CONF_BLE_SCANNER_MODE] = scanner_mode + current_mode = get_ble_scanner_mode(self.config_entry, info) + + if (scanner_mode == BLEScannerMode.DISABLED) != ( + current_mode == BLEScannerMode.DISABLED + ): + try: + await coordinator.client.set_ble_proxy( + scanner_mode != BLEScannerMode.DISABLED + ) + except SmlightConnectionError: + errors["base"] = "cannot_connect" + except SmlightAuthError: + errors["base"] = "invalid_auth" + self.config_entry.async_start_reauth(self.hass) + + if not errors: + return self.async_create_entry(title="", data=user_input) + + suggested_values = { + CONF_BLE_SCANNER_MODE: get_ble_scanner_mode(self.config_entry, info) + } + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + BLE_SCANNER_SCHEMA, user_input or suggested_values + ), + errors=errors, + ) + + async def async_step_no_settings( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options for devices without settings.""" + if user_input is not None: + return self.async_create_entry(title="", data={}) + + coordinator = self.config_entry.runtime_data.data + return self.async_show_form( + step_id="no_settings", + data_schema=vol.Schema({}), + description_placeholders={"model": coordinator.data.info.model}, + ) diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 0a45363f8adf..60acd7cdf883 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -1,6 +1,7 @@ """Constants for the SMLIGHT Zigbee integration.""" from datetime import timedelta +from enum import StrEnum import logging DOMAIN = "smlight" @@ -14,3 +15,14 @@ LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) UPTIME_DEVIATION = timedelta(seconds=5) + +CONF_BLE_SCANNER_MODE = "ble_scanner_mode" + + +class BLEScannerMode(StrEnum): + """BLE scanner mode.""" + + DISABLED = "disabled" + AUTO = "auto" + ACTIVE = "active" + PASSIVE = "passive" diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index b56674ee2978..19c6623dcc3d 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -191,6 +191,36 @@ "title": "SLZB core firmware update required" } }, + "options": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "init": { + "data": { + "ble_scanner_mode": "Bluetooth scanner mode" + }, + "data_description": { + "ble_scanner_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly." + }, + "description": "Configure SLZB Bluetooth settings. Switching between Disabled and another mode will reboot the device to apply the new configuration." + }, + "no_settings": { + "description": "This SMLIGHT device ({model}) does not support settings." + } + } + }, + "selector": { + "ble_scanner_mode": { + "options": { + "active": "Active (uses more device battery, fastest updates)", + "auto": "Auto (recommended, saves device battery)", + "disabled": "[%key:common::state::disabled%]", + "passive": "Passive (lowest device battery use, some details may be missing)" + } + } + }, "services": { "play_rtttl": { "description": "Play an RTTTL melody on the SMLIGHT device buzzer.", diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 752b045cffa3..844568d5fd8d 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -143,6 +143,7 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: api.actions.ambilight = AsyncMock(return_value=True) api.cmds = AsyncMock(spec_set=CmdWrapper) api.set_toggle = AsyncMock() + api.set_ble_proxy = AsyncMock(return_value=True) api.sse = MagicMock(spec_set=sseClient) yield api diff --git a/tests/components/smlight/test_bluetooth.py b/tests/components/smlight/test_bluetooth.py index eefe639ddf65..87c91b2be06d 100644 --- a/tests/components/smlight/test_bluetooth.py +++ b/tests/components/smlight/test_bluetooth.py @@ -6,6 +6,9 @@ from pysmlight import Info from pysmlight.models import BleFeatures import pytest +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.smlight.bluetooth import get_ble_scanner_mode +from homeassistant.components.smlight.const import CONF_BLE_SCANNER_MODE, BLEScannerMode from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -34,6 +37,9 @@ async def test_bluetooth_scanner_lifecycle( ) client_data = mock_connect_scanner.return_value + client_data.scanner.async_set_scanning_mode.assert_called_once_with( + BluetoothScanningMode.AUTO + ) client_data.client.start.assert_called_once() mock_bluetooth_scanner.assert_called_once_with( hass, @@ -85,5 +91,108 @@ async def test_bluetooth_not_started_for_classic_device( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + entry = mock_config_entry + assert entry.state is ConfigEntryState.LOADED + mock_connect_scanner.assert_not_called() + + coordinator = entry.runtime_data.data + assert coordinator.data.info.ble is None + + +@pytest.mark.parametrize( + "scanner_mode", + [ + "auto", + "active", + "passive", + ], +) +async def test_bluetooth_scanner_options( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect_scanner: MagicMock, + mock_ultima_client: MagicMock, + scanner_mode: str, +) -> None: + """Test SLZB BLE scanner options when proxy is expected to start.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + options={CONF_BLE_SCANNER_MODE: scanner_mode}, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_connect_scanner.assert_called_once_with( + source=mock_config_entry.unique_id, + name=mock_config_entry.title, + host=mock_config_entry.data[CONF_HOST], + port=5050, + ) + client_data = mock_connect_scanner.return_value + client_data.scanner.async_set_scanning_mode.assert_called_once_with( + BluetoothScanningMode(scanner_mode) + ) + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_bluetooth_scanner_options_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect_scanner: MagicMock, + mock_ultima_client: MagicMock, +) -> None: + """Test SLZB BLE scanner options when the scanner mode is disabled.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + options={CONF_BLE_SCANNER_MODE: "disabled"}, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED mock_connect_scanner.assert_not_called() + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_bluetooth_scanner_options_device_proxy_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect_scanner: MagicMock, + mock_ultima_client: MagicMock, +) -> None: + """Test SLZB BLE scanner options when device proxy is disabled on the hardware.""" + mock_ultima_client.get_info.side_effect = None + mock_ultima_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-Ultima3", + ble=BleFeatures(ble_enabled=True, proxy_enabled=False), + ) + + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + options={CONF_BLE_SCANNER_MODE: "passive"}, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_connect_scanner.assert_not_called() + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_get_ble_scanner_mode_no_ble( + hass: HomeAssistant, + mock_smlight_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test get_ble_scanner_mode when BLE is not supported by the hardware.""" + info = await mock_smlight_client.get_info() + assert get_ble_scanner_mode(mock_config_entry, info) is BLEScannerMode.DISABLED diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index c4f5d3cb3331..b93acf44e926 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -891,3 +891,187 @@ async def test_reconfigure_auth_error( assert mock_config_entry.unique_id == "aa:bb:cc:dd:ee:ff" assert len(mock_smlight_client.authenticate.mock_calls) == 2 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_options_flow( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow does not call the hardware API when switching between non-disabled modes.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "passive", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ble_scanner_mode": "passive", + } + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_options_flow_enable_from_disabled( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow toggles the remote adapter on when transitioning from disabled.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={"ble_scanner_mode": "disabled"} + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "passive", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ble_scanner_mode": "passive", + } + mock_ultima_client.set_ble_proxy.assert_called_once_with(True) + + +async def test_options_flow_error( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow error handling when disabling set_ble_proxy fails and succeeds on retry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + + mock_ultima_client.set_ble_proxy.side_effect = SmlightConnectionError + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "disabled", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_ultima_client.set_ble_proxy.side_effect = None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "disabled", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ble_scanner_mode": "disabled", + } + mock_ultima_client.set_ble_proxy.assert_called_with(False) + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_options_flow_no_ble( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow for device without BLE support redirects to no_settings step.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "no_settings" + assert result["description_placeholders"] == {"model": "SLZB-06p7"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {} + + +async def test_options_flow_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow returns cannot_connect error when config entry has not finished loading.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_options_flow_auth_error( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow auth error handling when disabling set_ble_proxy raises SmlightAuthError and reauth completes.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + + mock_ultima_client.set_ble_proxy.side_effect = SmlightAuthError + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "disabled", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + progress_flows = [ + flow + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == DOMAIN and flow["context"].get("source") == "reauth" + ] + assert len(progress_flows) == 1 + reauth_flow = progress_flows[0] + + mock_ultima_client.authenticate.side_effect = None + mock_ultima_client.check_auth_needed.return_value = True + + reauth_result = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert reauth_result["type"] is FlowResultType.ABORT + assert reauth_result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_HOST: MOCK_HOST, + } From 53ca9d2018bdd58be03e8672a5f33f65200ce661 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:13:37 +0800 Subject: [PATCH 160/707] Switchbot Cloud: Enable Webhook for AI Art Frame (#175823) --- homeassistant/components/switchbot_cloud/__init__.py | 7 ------- homeassistant/components/switchbot_cloud/const.py | 3 +++ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 4ee1116a78cb..a38b0335165e 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -297,13 +297,6 @@ async def make_device_data( ) devices_data.humidifiers.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "AI Art Frame": - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id - ) - devices_data.buttons.append((device, coordinator)) - devices_data.sensors.append((device, coordinator)) - devices_data.images.append((device, coordinator)) await make_new_device_data( hass, entry, api, device, devices_data, coordinators_by_id diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 835e5ce1077b..61de4193ac54 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -125,4 +125,7 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = { "Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "AI Art Frame": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BUTTON, Platform.IMAGE) + ), } From 5c6525b13a4b61ba81b737aef08b02e2d5a20c79 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:31:58 +0200 Subject: [PATCH 161/707] Use state attribute enums in derivative (#175848) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/derivative/config_flow.py | 14 ++++++-------- homeassistant/components/derivative/sensor.py | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 53e4e5dfa727..437eb92e336c 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -8,12 +8,7 @@ import voluptuous as vol from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_NAME, - CONF_SOURCE, - UnitOfTime, -) +from homeassistant.const import CONF_NAME, CONF_SOURCE, EntityStateAttribute, UnitOfTime from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -59,13 +54,16 @@ def entity_selector_compatible( """Return an entity selector which compatible entities.""" current = handler.hass.states.get(handler.options[CONF_SOURCE]) unit_of_measurement = ( - current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None + current.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + if current + else None ) entities = [ ent.entity_id for ent in handler.hass.states.async_all(ALLOWED_DOMAINS) - if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement + if ent.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + == unit_of_measurement and ent.domain in ALLOWED_DOMAINS ] diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ab7431fbef2c..0e25e72ea1b4 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -8,23 +8,22 @@ from typing import override import voluptuous as vol from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, DEVICE_CLASS_UNITS, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, RestoreSensor, SensorDeviceClass, SensorEntity, + SensorEntityCapabilityAttribute, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_SOURCE, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, Platform, UnitOfTime, ) @@ -243,7 +242,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if not source_state: return - source_class_raw = source_state.attributes.get(ATTR_DEVICE_CLASS) + source_class_raw = source_state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) source_class: SensorDeviceClass | None = None if isinstance(source_class_raw, str): try: @@ -252,7 +253,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity): source_class = None if self._string_unit_prefix is not None and self._string_unit_time is not None: original_unit = self._attr_native_unit_of_measurement - source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + source_unit = source_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) if ( ( source_class @@ -366,7 +369,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity): last_state = await self.async_get_last_state() if last_state: - self._attr_device_class = last_state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_device_class = last_state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) @override async def async_added_to_hass(self) -> None: @@ -540,7 +545,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # A negative derivative for a total increasing sensor likely indicates the # sensor has been reset. To prevent inaccurate data, discard this sample. if ( - new_state.attributes.get(ATTR_STATE_CLASS) + new_state.attributes.get(SensorEntityCapabilityAttribute.STATE_CLASS) == SensorStateClass.TOTAL_INCREASING and new_derivative < 0 ): From 5955c658ede610c242129b6df08d380acb548d0a Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:32:37 +0300 Subject: [PATCH 162/707] Bump victron-mqtt to 2026.7.0 (#175786) Co-authored-by: Joost Lekkerkerker --- .../components/victron_gx/manifest.json | 2 +- .../components/victron_gx/strings.json | 41 +++++++++++++++++-- requirements_all.txt | 2 +- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index dc81bb9aeb9b..61908b84b513 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["victron-mqtt==2026.6.6"], + "requirements": ["victron-mqtt==2026.7.0"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 084bbc8bdc60..2f88854cfd89 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -94,6 +94,7 @@ "temperature": "Temperature", "terminals_overheated": "Terminals overheated", "total_energy": "Total energy", + "total_pv_yield_system": "Total PV yield system", "total_pv_yield_user": "Total PV yield user", "total_yield": "Total yield", "unknown": "Unknown", @@ -325,9 +326,6 @@ "hub4_ac_grid_setpoint": { "name": "AC grid setpoint" }, - "multi_ess_ac_power_setpoint": { - "name": "ESS AC power setpoint" - }, "multi_ess_min_soc_limit": { "name": "ESS minimum SoC limit" }, @@ -493,6 +491,14 @@ "sell": "Sell" } }, + "system_vrm_portal_mode": { + "name": "VRM portal access level", + "state": { + "full": "[%key:common::state::full%]", + "off": "[%key:common::state::off%]", + "read_only": "Read-only" + } + }, "vebus_inverter_mode": { "state": { "charger_only": "[%key:component::victron_gx::common::charger_only%]", @@ -1317,7 +1323,7 @@ } }, "inverter_total_pv_yield_system": { - "name": "Total PV yield system" + "name": "[%key:component::victron_gx::common::total_pv_yield_system%]" }, "inverter_total_pv_yield_user": { "name": "[%key:component::victron_gx::common::total_pv_yield_user%]" @@ -1398,6 +1404,9 @@ "multi_dc_temperature": { "name": "[%key:component::victron_gx::common::dc_temperature%]" }, + "multi_ess_ac_power_setpoint": { + "name": "ESS AC power setpoint" + }, "multi_ess_mode": { "name": "[%key:component::victron_gx::common::ess_mode%]", "state": { @@ -1628,6 +1637,9 @@ "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, + "solarcharger_temperature": { + "name": "[%key:component::victron_gx::common::temperature%]" + }, "solarcharger_time_in_absorption_today": { "name": "Time in absorption today" }, @@ -1637,6 +1649,9 @@ "solarcharger_time_in_float_today": { "name": "Time in float today" }, + "solarcharger_total_pv_yield_system": { + "name": "[%key:component::victron_gx::common::total_pv_yield_system%]" + }, "solarcharger_tracker_tracker_current": { "name": "PV tracker {tracker} current" }, @@ -2254,6 +2269,9 @@ "system_dvcc": { "name": "DVCC" }, + "system_ess_always_peak_shave": { + "name": "ESS always peak shave" + }, "system_ess_battery_use": { "name": "ESS only critical loads from battery" }, @@ -2272,6 +2290,18 @@ "vebus_device_device_number_power_assist_enabled": { "name": "{device_number} PowerAssist enabled" }, + "vebus_hub4_disable_charge": { + "name": "Hub4 disable charge" + }, + "vebus_hub4_do_not_feed_in_overvoltage": { + "name": "Hub4 do not feed in on overvoltage" + }, + "vebus_hub4_fix_solar_offset_100mv": { + "name": "Hub4 fix solar offset to 100mV" + }, + "vebus_hub4_target_power_is_max_feed_in": { + "name": "Hub4 target power is max feed-in" + }, "vebus_inverter_ignoreacin1_onoff_control": { "name": "Control ignore AC-in-1" }, @@ -2280,6 +2310,9 @@ }, "vebus_inverter_setting_alarm_grid_lost": { "name": "Grid lost alarm setting" + }, + "vebus_pvinverter_disable": { + "name": "Vebus PV inverter disable" } }, "time": { diff --git a/requirements_all.txt b/requirements_all.txt index 474f943aab37..61c92211fe42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3293,7 +3293,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx -victron-mqtt==2026.6.6 +victron-mqtt==2026.7.0 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.12 From 1f83b297654b29f05c5f4051e07080b04102fea0 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:44:51 +0200 Subject: [PATCH 163/707] Bump uiprotect to 15.4.3 (#175861) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 3bffc38f42cf..f3f2c817907d 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.4.2"] + "requirements": ["uiprotect==15.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 61c92211fe42..32f278cb7b27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3240,7 +3240,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.4.2 +uiprotect==15.4.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From f110426542778736a8ea261ba205b2056a77716c Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:46:44 +0200 Subject: [PATCH 164/707] Bump aioaquarite to 0.6.1 (#175857) Co-authored-by: Claude --- homeassistant/components/vistapool/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vistapool/manifest.json b/homeassistant/components/vistapool/manifest.json index bc07c75d6683..6e8106720570 100644 --- a/homeassistant/components/vistapool/manifest.json +++ b/homeassistant/components/vistapool/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["aioaquarite"], "quality_scale": "bronze", - "requirements": ["aioaquarite==0.5.1"] + "requirements": ["aioaquarite==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32f278cb7b27..d47877ce7de3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -206,7 +206,7 @@ aioapcaccess==1.0.0 aioaquacell==1.0.0 # homeassistant.components.vistapool -aioaquarite==0.5.1 +aioaquarite==0.6.1 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 From 287b5743a67be926226e0cba2f64d4e9c777d35b Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 7 Jul 2026 14:50:06 +0200 Subject: [PATCH 165/707] Re-import Rexel client credential in Overkiz config flow (#175852) --- .../components/overkiz/config_flow.py | 22 +++++++++++++++++- tests/components/overkiz/test_config_flow.py | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index ec3fcd218b8f..5793364ef955 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -11,7 +11,11 @@ from pyoverkiz.auth.credentials import ( UsernamePasswordCredentials, ) from pyoverkiz.client import GatewayCandidate, OverkizClient -from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS +from pyoverkiz.const import ( + REXEL_OAUTH_CLIENT_ID, + SERVERS_WITH_LOCAL_API, + SUPPORTED_SERVERS, +) from pyoverkiz.enums import APIType, Server from pyoverkiz.exceptions import ( ApplicationNotAllowedError, @@ -28,6 +32,10 @@ from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway import voluptuous as vol +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -179,6 +187,18 @@ class OverkizConfigFlow( description_placeholders={"local_api_docs": LOCAL_API_DOCS_URL}, ) + @override + async def async_step_pick_implementation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Start the Rexel OAuth2 flow, re-importing the credential if removed.""" + await async_import_client_credential( + self.hass, + DOMAIN, + ClientCredential(REXEL_OAUTH_CLIENT_ID, "", name="Rexel"), + ) + return await super().async_step_pick_implementation(user_input) + async def async_step_cloud( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index d1a2cb399a7a..2c4ef7695f5e 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1136,6 +1136,29 @@ async def test_rexel_full_flow_single_gateway( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_rexel_flow_reimports_removed_credential( + hass: HomeAssistant, +) -> None: + """The Rexel flow re-imports its client credential if the user removed it.""" + assert await async_setup_component(hass, "application_credentials", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"hub": "rexel"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_type": "cloud"} + ) + + # Reaching the OAuth2 external step proves an implementation was available, + # i.e. the credential was re-imported despite not being present beforehand. + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert REXEL_OAUTH_AUTHORIZE_URL in result["url"] + + @pytest.mark.usefixtures("current_request_with_host", "setup_rexel_credentials") async def test_rexel_full_flow_multiple_gateways( hass: HomeAssistant, From fe212883bea06091577444e1a563950d532ea11f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:50:40 +0200 Subject: [PATCH 166/707] Use state attribute enums in Dynalite (#175850) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/dynalite/cover.py | 4 ++-- homeassistant/components/dynalite/light.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 672201b2c37b..d6040a427161 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -3,9 +3,9 @@ from typing import Any, override from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, CoverDeviceClass, CoverEntity, + CoverEntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -89,7 +89,7 @@ class DynaliteCover(DynaliteBase, CoverEntity): @override def initialize_state(self, state): """Initialize the state from cache.""" - target_level = state.attributes.get(ATTR_CURRENT_POSITION) + target_level = state.attributes.get(CoverEntityStateAttribute.CURRENT_POSITION) if target_level is not None: self._device.init_level(target_level) diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index 33a8c23c547d..c818f51b3602 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -2,7 +2,11 @@ from typing import Any, override -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityStateAttribute, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,6 +56,6 @@ class DynaliteLight(DynaliteBase, LightEntity): @override def initialize_state(self, state): """Initialize the state from cache.""" - target_level = state.attributes.get(ATTR_BRIGHTNESS) + target_level = state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) if target_level is not None: self._device.init_level(target_level) From e5469dbc916be7e247c1ed5e3314b256446d7cae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:51:51 +0200 Subject: [PATCH 167/707] Use CounterEntityStateAttribute enum in counter triggers (#175853) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/counter/trigger.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/counter/trigger.py b/homeassistant/components/counter/trigger.py index 8913b703b901..c5431367b80c 100644 --- a/homeassistant/components/counter/trigger.py +++ b/homeassistant/components/counter/trigger.py @@ -2,7 +2,6 @@ from typing import override -from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -12,7 +11,8 @@ from homeassistant.helpers.trigger import ( Trigger, ) -from . import CONF_INITIAL, DOMAIN +from . import DOMAIN +from .const import CounterEntityStateAttribute def _is_integer_state(state: State) -> bool: @@ -74,7 +74,9 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the new state matches the expected state(s).""" - if (max_value := state.attributes.get(CONF_MAXIMUM)) is None: + if ( + max_value := state.attributes.get(CounterEntityStateAttribute.MAXIMUM) + ) is None: return False return state.state == str(max_value) @@ -89,7 +91,9 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the new state matches the expected state(s).""" - if (min_value := state.attributes.get(CONF_MINIMUM)) is None: + if ( + min_value := state.attributes.get(CounterEntityStateAttribute.MINIMUM) + ) is None: return False return state.state == str(min_value) @@ -104,7 +108,9 @@ class CounterResetTrigger(CounterValueBaseTrigger): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the new state matches the expected state(s).""" - if (init_state := state.attributes.get(CONF_INITIAL)) is None: + if ( + init_state := state.attributes.get(CounterEntityStateAttribute.INITIAL) + ) is None: return False return state.state == str(init_state) From 8f94c49a7c1639c3f405af02efac0295647fb2b1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:52:22 +0200 Subject: [PATCH 168/707] Use EntityStateAttribute enum in configurator (#175847) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/configurator/__init__.py | 6 +++--- tests/components/configurator/test_init.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index c8b99ed1d516..149fccc0a84e 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -14,7 +14,7 @@ from typing import Any import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME +from homeassistant.const import EntityStateAttribute from homeassistant.core import ( HassJob, HomeAssistant, @@ -181,8 +181,8 @@ class Configurator: data = { ATTR_CONFIGURE_ID: request_id, ATTR_FIELDS: fields, - ATTR_FRIENDLY_NAME: name, - ATTR_ENTITY_PICTURE: entity_picture, + EntityStateAttribute.FRIENDLY_NAME: name, + EntityStateAttribute.ENTITY_PICTURE: entity_picture, } data.update( diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index 5fabcbd9954b..8796370cf782 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta import pytest from homeassistant.components import configurator -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import EntityStateAttribute from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import Unauthorized from homeassistant.util import dt as dt_util @@ -40,7 +40,7 @@ async def test_request_least_info(hass: HomeAssistant) -> None: async def test_request_all_info(hass: HomeAssistant) -> None: """Test request config with all possible info.""" exp_attr = { - ATTR_FRIENDLY_NAME: "Test Request", + EntityStateAttribute.FRIENDLY_NAME: "Test Request", configurator.ATTR_DESCRIPTION: """config description [link name](link url) @@ -48,7 +48,7 @@ async def test_request_all_info(hass: HomeAssistant) -> None: ![Description image](config image url)""", configurator.ATTR_SUBMIT_CAPTION: "config submit caption", configurator.ATTR_FIELDS: [], - configurator.ATTR_ENTITY_PICTURE: "config entity picture", + EntityStateAttribute.ENTITY_PICTURE: "config entity picture", configurator.ATTR_CONFIGURE_ID: configurator.async_request_config( hass, name="Test Request", From 023f8b03ef8e60607162ed626a15779149beea1d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:05:00 +0200 Subject: [PATCH 169/707] Use attribution property in buienradar (#175844) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/buienradar/sensor.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 1f575d26aeab..4850a83af4b4 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -28,7 +28,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, @@ -905,17 +904,15 @@ class BrSensor(SensorEntity): # update all other sensors self._attr_native_value = data.get(sensor_type) + self._attr_attribution = data.get(ATTRIBUTION) if sensor_type.startswith(PRECIPITATION_FORECAST): - result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} + result = {} if self._timeframe is not None: result[TIMEFRAME_LABEL] = f"{self._timeframe} min" self._attr_extra_state_attributes = result - result = { - ATTR_ATTRIBUTION: data.get(ATTRIBUTION), - STATIONNAME_LABEL: data.get(STATIONNAME), - } + result = {STATIONNAME_LABEL: data.get(STATIONNAME)} if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime local_dt = dt_util.as_local(self._measured) From 6460ee7b72b7f2403080e468aa68e91c727ec288 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:05:13 +0200 Subject: [PATCH 170/707] Use EventEntityStateAttribute enum in Bring (#175843) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/bring/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bring/services.py b/homeassistant/components/bring/services.py index 3d5b20e7b6dd..7b84835f4a83 100644 --- a/homeassistant/components/bring/services.py +++ b/homeassistant/components/bring/services.py @@ -9,7 +9,7 @@ from bring_api import ( ) import voluptuous as vol -from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.event import EventEntityStateAttribute from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -70,7 +70,7 @@ def async_setup_services(hass: HomeAssistant) -> None: list_uuid = entity.unique_id.split("_")[1] - activity = state.attributes[ATTR_EVENT_TYPE] + activity = state.attributes[EventEntityStateAttribute.EVENT_TYPE] reaction: ReactionType = call.data[ATTR_REACTION] From 97751e1e47e93c1b10b8dae8ab89a1bfe1b8471b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:05:27 +0200 Subject: [PATCH 171/707] Use attribution property in Blink alarm control panel (#175842) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/blink/alarm_control_panel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 95f271764a83..981bff1fff71 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -11,7 +11,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -43,6 +42,7 @@ class BlinkSyncModuleHA( ): """Representation of a Blink Alarm Control Panel.""" + _attr_attribution = DEFAULT_ATTRIBUTION _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_code_arm_required = False _attr_has_entity_name = True @@ -77,7 +77,6 @@ class BlinkSyncModuleHA( """Update attributes for alarm control panel.""" self.sync.attributes["network_info"] = self.api.networks self.sync.attributes["associated_cameras"] = list(self.sync.cameras) - self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes self._attr_alarm_state = ( AlarmControlPanelState.ARMED_AWAY From 6a927ad4f6beb60927294e754155c640e2795b16 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:05:55 +0200 Subject: [PATCH 172/707] Use state attribute enums in August (#175840) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/august/lock.py | 14 ++++++++++---- homeassistant/components/august/sensor.py | 8 ++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 12fd0806245d..a4cfd3a490be 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -8,7 +8,11 @@ from yalexs.activity import ActivityType from yalexs.lock import Lock, LockOperation, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity -from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature +from homeassistant.components.lock import ( + LockEntity, + LockEntityFeature, + LockEntityStateAttribute, +) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -134,7 +138,7 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity): @override async def async_added_to_hass(self) -> None: - """Restore ATTR_CHANGED_BY on startup. + """Restore changed_by on startup. It is likely no longer in the activity log. """ @@ -143,5 +147,7 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity): if not (last_state := await self.async_get_last_state()): return - if ATTR_CHANGED_BY in last_state.attributes: - self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY] + if LockEntityStateAttribute.CHANGED_BY in last_state.attributes: + self._attr_changed_by = last_state.attributes[ + LockEntityStateAttribute.CHANGED_BY + ] diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 348b2b8e2e35..740640dc69df 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -17,10 +17,10 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_ENTITY_PICTURE, PERCENTAGE, STATE_UNAVAILABLE, EntityCategory, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -170,7 +170,7 @@ class AugustOperatorSensor(AugustEntity, RestoreSensor): @override async def async_added_to_hass(self) -> None: - """Restore ATTR_CHANGED_BY on startup. + """Restore attributes on startup. It is likely no longer in the activity log. """ @@ -187,8 +187,8 @@ class AugustOperatorSensor(AugustEntity, RestoreSensor): self._attr_native_value = last_sensor_state.native_value last_attrs = last_state.attributes - if ATTR_ENTITY_PICTURE in last_attrs: - self._attr_entity_picture = last_attrs[ATTR_ENTITY_PICTURE] + if EntityStateAttribute.ENTITY_PICTURE in last_attrs: + self._attr_entity_picture = last_attrs[EntityStateAttribute.ENTITY_PICTURE] if ATTR_OPERATION_REMOTE in last_attrs: self._operated_remote = last_attrs[ATTR_OPERATION_REMOTE] if ATTR_OPERATION_KEYPAD in last_attrs: From b9841183674188ebcba33d0f07a1b646a9c8167f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 7 Jul 2026 15:06:19 +0200 Subject: [PATCH 173/707] Add missing holiday categories in holiday (#175841) --- homeassistant/components/holiday/strings.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json index b44b25fa03ba..7bb66015e5f0 100644 --- a/homeassistant/components/holiday/strings.json +++ b/homeassistant/components/holiday/strings.json @@ -51,20 +51,33 @@ "selector": { "device_class": { "options": { + "albanian": "Albanian", "armed_forces": "Armed forces", + "armenian": "Armenian", "bank": "Bank", + "bosnian": "Bosnian", "catholic": "Catholic", "chinese": "Chinese", "christian": "Christian", + "de_facto": "De facto", "government": "Government", "half_day": "Half day", "hebrew": "Hebrew", "hindu": "Hindu", "islamic": "Islamic", "optional": "Optional", + "orthodox": "Orthodox", + "protestant": "Protestant", + "public": "Public", + "roma": "Roma", + "sabian": "Sabian", "school": "School", + "serbian": "Serbian", + "turkish": "Turkish", "unofficial": "Unofficial", - "workday": "Workday" + "vlach": "Vlach", + "workday": "Workday", + "yazidi": "Yazidi" } } }, From 4114ab7b747bbfeda22e39d77bd5ff82ac9b2969 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:06:30 +0200 Subject: [PATCH 174/707] Use EntityStateAttribute enum in analytics (#175838) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/analytics/analytics.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 920c36d97b7b..fd30f1c6fafb 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -28,9 +28,9 @@ from homeassistant.components.recorder import ( ) from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_DOMAIN, BASE_PLATFORMS, + EntityStateAttribute, __version__ as HA_VERSION, ) from homeassistant.core import ( @@ -915,7 +915,9 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901 # It is also not present, if entity is not in the state machine, # which can happen for disabled entities. "assumed_state": ( - entity_state.attributes.get(ATTR_ASSUMED_STATE, False) + entity_state.attributes.get( + EntityStateAttribute.ASSUMED_STATE, False + ) if entity_state is not None else None ), From 426c77d9f7284366a9c149edefb9e5f65f70c1e2 Mon Sep 17 00:00:00 2001 From: Krisjanis Lejejs Date: Tue, 7 Jul 2026 16:08:54 +0300 Subject: [PATCH 175/707] Add cloud page onbording prefs (#175762) --- homeassistant/components/cloud/const.py | 3 + homeassistant/components/cloud/http_api.py | 49 ++++++++++ homeassistant/components/cloud/prefs.py | 37 +++++++ tests/components/cloud/test_http_api.py | 106 +++++++++++++++++++++ 4 files changed, 195 insertions(+) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index bb324eea1b21..2dd10c9b59c9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -46,6 +46,9 @@ PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_GOOGLE_CONNECTED = "google_connected" PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" PREF_ENABLE_CLOUD_ICE_SERVERS = "cloud_ice_servers_enabled" +PREF_ONBOARDED_ITEMS = "onboarded_items" +PREF_ONBOARDING_POSTPONED_UNTIL = "onboarding_postponed_until" +ONBOARDING_ITEMS = {"remote", "backup", "voice", "streaming"} DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index fbbfaab73d56..a962c1fb6853 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress import dataclasses +from datetime import timedelta from functools import wraps from http import HTTPStatus import json @@ -39,6 +40,7 @@ from homeassistant.loader import ( async_get_custom_components, async_get_loaded_integration, ) +from homeassistant.util import dt as dt_util from homeassistant.util.location import async_detect_location_info from homeassistant.util.package import async_get_installed_packages @@ -50,6 +52,7 @@ from .const import ( DATA_CLOUD_LOG_HANDLER, EVENT_CLOUD_EVENT, LOGIN_MFA_TIMEOUT, + ONBOARDING_ITEMS, PREF_ALEXA_REPORT_STATE, PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, @@ -99,6 +102,8 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) websocket_api.async_register_command(hass, websocket_webrtc_ice_servers) + websocket_api.async_register_command(hass, websocket_cloud_onboarding_postpone) + websocket_api.async_register_command(hass, websocket_cloud_onboarding_complete) websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) @@ -844,6 +849,48 @@ async def websocket_update_prefs( connection.send_message(websocket_api.result_message(msg["id"])) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command({vol.Required("type"): "cloud/onboarding/postpone"}) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def websocket_cloud_onboarding_postpone( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle request to postpone onboarding.""" + cloud = hass.data[DATA_CLOUD] + postponed_until = (dt_util.utcnow() + timedelta(hours=24)).isoformat() + await cloud.client.prefs.async_update(onboarding_postponed_until=postponed_until) + connection.send_result(msg["id"], await _account_data(hass, cloud)) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/onboarding/complete", + vol.Required("items"): [vol.In(ONBOARDING_ITEMS)], + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def websocket_cloud_onboarding_complete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle request to complete onboarding items.""" + cloud = hass.data[DATA_CLOUD] + onboarded_items = list(cloud.client.prefs.onboarded_items) + new_items = [item for item in msg["items"] if item not in onboarded_items] + if new_items: + onboarded_items.extend(dict.fromkeys(new_items)) + await cloud.client.prefs.async_update(onboarded_items=onboarded_items) + connection.send_result(msg["id"], await _account_data(hass, cloud)) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( @@ -930,6 +977,8 @@ async def _account_data( "google_local_connected": google_config.is_local_connected, "logged_in": True, "prefs": client.prefs.as_dict(), + "onboarding_completed": client.prefs.onboarding_completed, + "onboarding_postponed": client.prefs.onboarding_postponed, "remote_certificate": certificate, "remote_certificate_status": remote.certificate_status, "remote_connected": remote.is_connected, diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 3def31727c27..7fcf1fbd7d42 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -15,6 +15,7 @@ from homeassistant.components.google_assistant.http import ( # pylint: disable= from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.util import dt as dt_util from homeassistant.util.logging import async_create_catching_coro from .const import ( @@ -23,6 +24,7 @@ from .const import ( DEFAULT_GOOGLE_REPORT_STATE, DEFAULT_TTS_DEFAULT_VOICE, DOMAIN, + ONBOARDING_ITEMS, PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, @@ -41,6 +43,8 @@ from .const import ( PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SETTINGS_VERSION, PREF_INSTANCE_ID, + PREF_ONBOARDED_ITEMS, + PREF_ONBOARDING_POSTPONED_UNTIL, PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_DOMAIN, PREF_TTS_DEFAULT_VOICE, @@ -175,6 +179,8 @@ class CloudPreferences: google_settings_version: int | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, remote_domain: str | None | UndefinedType = UNDEFINED, + onboarded_items: list[str] | UndefinedType = UNDEFINED, + onboarding_postponed_until: str | None | UndefinedType = UNDEFINED, remote_enabled: bool | UndefinedType = UNDEFINED, tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, ) -> None: @@ -197,6 +203,8 @@ class CloudPreferences: (PREF_GOOGLE_REPORT_STATE, google_report_state), (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), + (PREF_ONBOARDED_ITEMS, onboarded_items), + (PREF_ONBOARDING_POSTPONED_UNTIL, onboarding_postponed_until), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), (PREF_REMOTE_DOMAIN, remote_domain), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), @@ -247,6 +255,8 @@ class CloudPreferences: PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_ONBOARDED_ITEMS: self.onboarded_items, + PREF_ONBOARDING_POSTPONED_UNTIL: self.onboarding_postponed_until, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, } @@ -359,6 +369,31 @@ class CloudPreferences: """Return the instance ID.""" return self._prefs.get(PREF_INSTANCE_ID) + @property + def onboarded_items(self) -> list[str]: + """Return list of completed onboarding items.""" + onboarded_items: list[str] = self._prefs.get(PREF_ONBOARDED_ITEMS, []) + return onboarded_items + + @property + def onboarding_completed(self) -> bool: + """Return if all onboarding items are completed.""" + return ONBOARDING_ITEMS.issubset(self.onboarded_items) + + @property + def onboarding_postponed_until(self) -> str | None: + """Return the datetime until which onboarding is postponed.""" + return self._prefs.get(PREF_ONBOARDING_POSTPONED_UNTIL) + + @property + def onboarding_postponed(self) -> bool: + """Return if onboarding is currently postponed.""" + if (postponed_until := self.onboarding_postponed_until) is None: + return False + if (parsed := dt_util.parse_datetime(postponed_until)) is None: + return False + return parsed > dt_util.utcnow() + @property def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice. @@ -430,6 +465,8 @@ class CloudPreferences: PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_ONBOARDED_ITEMS: [], + PREF_ONBOARDING_POSTPONED_UNTIL: None, PREF_REMOTE_DOMAIN: None, PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index ce450b89cc6a..6d3d588027ae 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -959,6 +959,8 @@ async def test_websocket_status( "alexa_default_expose": DEFAULT_EXPOSED_DOMAINS, "alexa_report_state": True, "google_report_state": True, + "onboarded_items": [], + "onboarding_postponed_until": None, "remote_allow_remote_enable": True, "remote_enabled": False, "cloud_ice_servers_enabled": True, @@ -989,6 +991,8 @@ async def test_websocket_status( "remote_certificate": None, "http_use_ssl": False, "active_subscription": True, + "onboarding_completed": False, + "onboarding_postponed": False, } @@ -1235,6 +1239,106 @@ async def test_websocket_update_preferences_no_token( assert response["error"]["code"] == "alexa_relink" +async def test_websocket_cloud_onboarding_postpone( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test postponing onboarding.""" + client = await hass_ws_client(hass) + + assert cloud.client.prefs.onboarding_postponed is False + + await client.send_json_auto_id({"type": "cloud/onboarding/postpone"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["onboarding_postponed"] is True + assert cloud.client.prefs.onboarding_postponed_until is not None + + freezer.tick(datetime.timedelta(hours=25)) + + await client.send_json_auto_id({"type": "cloud/status"}) + response = await client.receive_json() + + assert response["result"]["onboarding_postponed"] is False + + +async def test_websocket_cloud_onboarding_complete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, +) -> None: + """Test completing onboarding items.""" + client = await hass_ws_client(hass) + + assert cloud.client.prefs.onboarded_items == [] + assert cloud.client.prefs.onboarding_completed is False + + # Complete a subset of items + await client.send_json_auto_id( + {"type": "cloud/onboarding/complete", "items": ["remote", "backup"]} + ) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.prefs.onboarded_items == ["remote", "backup"] + assert response["result"]["onboarding_completed"] is False + + # Already-completed items are ignored, only new ones are added + await client.send_json_auto_id( + { + "type": "cloud/onboarding/complete", + "items": ["remote", "voice", "streaming"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.prefs.onboarding_completed is True + assert response["result"]["onboarding_completed"] is True + assert cloud.client.prefs.onboarded_items == [ + "remote", + "backup", + "voice", + "streaming", + ] + + # Completing already-completed items is a no-op + await client.send_json_auto_id( + {"type": "cloud/onboarding/complete", "items": ["remote", "backup"]} + ) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.prefs.onboarded_items == [ + "remote", + "backup", + "voice", + "streaming", + ] + + +async def test_websocket_cloud_onboarding_complete_invalid_item( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, +) -> None: + """Test completing an invalid onboarding item.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "cloud/onboarding/complete", "items": ["remote", "invalid"]} + ) + response = await client.receive_json() + + assert not response["success"] + + async def test_enabling_webhook( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1789,6 +1893,8 @@ async def test_support_package_requires_admin( {"type": "cloud/update_prefs", "alexa_report_state": True}, {"type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"}, {"type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"}, + {"type": "cloud/onboarding/postpone"}, + {"type": "cloud/onboarding/complete", "items": ["remote"]}, ], ) async def test_ws_commands_require_admin( From ad8b4b2d6ecbda5968d32fbf034f0ea22b7a96b0 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 7 Jul 2026 21:16:38 +0800 Subject: [PATCH 176/707] Fixed YoLink water meter controller valve status showing as "Unknown" (#175835) --- homeassistant/components/yolink/valve.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 4c0d4e6464b5..2842714454dd 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -62,6 +62,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER and not device.device_model_name.startswith(DEV_MODEL_WATER_METER_YS5007) ), + should_update_entity=lambda value: value is not None, ), YoLinkValveEntityDescription( key="valve_1_state", @@ -71,6 +72,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( exists_fn=lambda device: ( device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER ), + should_update_entity=lambda value: value is not None, channel_index=0, ), YoLinkValveEntityDescription( @@ -81,6 +83,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( exists_fn=lambda device: ( device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER ), + should_update_entity=lambda value: value is not None, channel_index=1, ), YoLinkValveEntityDescription( From e18a08d870df355757c3207c2464197595b35812 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 7 Jul 2026 23:25:02 +1000 Subject: [PATCH 177/707] Fix unhandled KeyError on Tesla Fleet homelink button (#175730) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Josef Zweck --- .../components/tesla_fleet/button.py | 18 ++++++++++---- .../components/tesla_fleet/strings.json | 3 +++ tests/components/tesla_fleet/test_button.py | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py index 836bb06d985f..91a13fe176d6 100644 --- a/homeassistant/components/tesla_fleet/button.py +++ b/homeassistant/components/tesla_fleet/button.py @@ -8,9 +8,11 @@ from tesla_fleet_api.const import Scope from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry +from .const import DOMAIN from .entity import TeslaFleetVehicleEntity from .helpers import handle_vehicle_command from .models import TeslaFleetVehicleData @@ -48,10 +50,7 @@ DESCRIPTIONS: tuple[TeslaFleetButtonEntityDescription, ...] = ( ), TeslaFleetButtonEntityDescription( key="homelink", - func=lambda self: self.api.trigger_homelink( - lat=self.coordinator.data["drive_state_latitude"], - lon=self.coordinator.data["drive_state_longitude"], - ), + func=lambda self: self.async_trigger_homelink(), ), ) @@ -89,6 +88,17 @@ class TeslaFleetButtonEntity(TeslaFleetVehicleEntity, ButtonEntity): def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" + async def async_trigger_homelink(self) -> Any: + """Trigger Homelink, which requires the vehicle location.""" + if (lat := self.coordinator.data.get("drive_state_latitude")) is None or ( + lon := self.coordinator.data.get("drive_state_longitude") + ) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="homelink_no_location", + ) + return await self.api.trigger_homelink(lat=lat, lon=lon) + @override async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 3e36a827e5c9..8b9cfe120569 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -609,6 +609,9 @@ "command_reason": { "message": "Command was unsuccessful: {reason}" }, + "homelink_no_location": { + "message": "Vehicle location is not available. Ensure the vehicle is awake and that the location scope has been granted." + }, "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index 9eb12961dfa2..e1de5866a5f3 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -68,6 +68,30 @@ async def test_press( command.assert_called_once() +async def test_homelink_no_location( + hass: HomeAssistant, normal_config_entry: MockConfigEntry +) -> None: + """Test pressing homelink without vehicle location raises a translated error.""" + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + + coordinator = normal_config_entry.runtime_data.vehicles[0].coordinator + coordinator.data.pop("drive_state_latitude", None) + coordinator.data.pop("drive_state_longitude", None) + + with ( + patch("tesla_fleet_api.tesla.VehicleFleet.trigger_homelink") as command, + pytest.raises(HomeAssistantError) as error, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ["button.test_homelink"]}, + blocking=True, + ) + assert error.value.translation_key == "homelink_no_location" + command.assert_not_called() + + async def test_press_signing_error( hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_products: AsyncMock ) -> None: From d2703fdbc3705ced6c17d449c960c6932df6aa19 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 7 Jul 2026 15:27:58 +0200 Subject: [PATCH 178/707] Improve time handling in Nord Pool (#175771) --- .../components/nordpool/coordinator.py | 46 ++++++++++--------- tests/components/nordpool/conftest.py | 23 ++++++++++ tests/components/nordpool/test_coordinator.py | 36 ++++++++------- 3 files changed, 66 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index 01872567d2ed..2366ef3cf6d0 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, override import aiohttp +from aiozoneinfo import get_time_zone from pynordpool import ( Currency, DeliveryPeriodData, @@ -27,6 +28,13 @@ from .const import CONF_AREAS, DOMAIN, LOGGER if TYPE_CHECKING: from . import NordPoolConfigEntry +NORDPOOL_TIMEZONE = get_time_zone("Europe/Oslo") + + +def get_nordpool_current_time() -> datetime: + """Return the Nord Pool current time.""" + return dt_util.utcnow().astimezone(NORDPOOL_TIMEZONE) + class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): """A Nord Pool Data Update Coordinator.""" @@ -47,26 +55,20 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): def get_next_data_interval(self, now: datetime) -> datetime: """Compute next time an update should occur.""" - next_hour = dt_util.utcnow() + timedelta(hours=1) - next_run = datetime( - next_hour.year, - next_hour.month, - next_hour.day, - next_hour.hour, - tzinfo=dt_util.UTC, - ) - LOGGER.debug("Next data update at %s", next_run) + next_data_run = now + timedelta(hours=1) + next_run = next_data_run.replace(minute=0, second=0, microsecond=0) + LOGGER.debug("Next data update at %s", next_run.astimezone(NORDPOOL_TIMEZONE)) return next_run def get_next_15_interval(self, now: datetime) -> datetime: """Compute next time we need to notify listeners.""" - next_run = dt_util.utcnow() + timedelta(minutes=15) + next_run = now + timedelta(minutes=15) next_minute = next_run.minute // 15 * 15 - next_run = next_run.replace( - minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC - ) + next_run = next_run.replace(minute=next_minute, second=0, microsecond=0) - LOGGER.debug("Next listener update at %s", next_run) + LOGGER.debug( + "Next listener update at %s", next_run.astimezone(NORDPOOL_TIMEZONE) + ) return next_run @override @@ -85,14 +87,14 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): self.listener_unsub = async_track_point_in_utc_time( self.hass, self.update_listeners, - self.get_next_15_interval(dt_util.utcnow()), + self.get_next_15_interval(now), ) self.async_update_listeners() async def fetch_data(self, now: datetime, initial: bool = False) -> None: """Fetch data from Nord Pool.""" self.data_unsub = async_track_point_in_utc_time( - self.hass, self.fetch_data, self.get_next_data_interval(dt_util.utcnow()) + self.hass, self.fetch_data, self.get_next_data_interval(now) ) if self.config_entry.pref_disable_polling and not initial: return @@ -107,7 +109,7 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): """Fetch data from Nord Pool.""" data = await self.api_call() if data and data.entries: - current_day = dt_util.now().date() + current_day = get_nordpool_current_time().date() if current_day in data.entries: LOGGER.debug("Data for current day found") return data @@ -129,9 +131,9 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): try: data = await self.client.async_get_delivery_periods( [ - dt_util.now() - timedelta(days=1), - dt_util.now(), - dt_util.now() + timedelta(days=1), + get_nordpool_current_time() - timedelta(days=1), + get_nordpool_current_time(), + get_nordpool_current_time() + timedelta(days=1), ], Currency(self.config_entry.data[CONF_CURRENCY]), self.config_entry.data[CONF_AREAS], @@ -164,10 +166,10 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): def get_data_current_day(self) -> DeliveryPeriodData: """Return the current day data.""" - current_day = dt_util.now().date() + current_day = get_nordpool_current_time().date() return self.data.entries[current_day] def get_data_tomorrow(self) -> DeliveryPeriodData | None: """Return tomorrow's day data if available.""" - tomorrow = dt_util.now().date() + timedelta(days=1) + tomorrow = get_nordpool_current_time().date() + timedelta(days=1) return self.data.entries.get(tomorrow) diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index 25ccfb852ecd..ab284da16b63 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -1,6 +1,7 @@ """Fixtures for the Nord Pool integration.""" from collections.abc import AsyncGenerator +from http import HTTPStatus import json from typing import Any @@ -108,6 +109,28 @@ async def get_data_from_library( }, json=load_json[2], ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2025-10-03", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2025-10-04", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) client = NordPoolClient(aioclient_mock.create_session(hass.loop)) yield client await client._session.close() diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index b03faa74081e..8f3364b15dc6 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -29,7 +29,7 @@ from . import ENTRY_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.freeze_time("2025-10-01T10:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T10:00:00+02:00") async def test_coordinator( hass: HomeAssistant, get_client: NordPoolClient, @@ -48,11 +48,12 @@ async def test_coordinator( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.67405" + assert state.state == "1.03744" - assert "Next data update at 2025-10-01 11:00:00+00:00" in caplog.text - assert "Next listener update at 2025-10-01 10:15:00+00:00" in caplog.text + assert "Next data update at 2025-10-01 11:00:00+02:00" in caplog.text + assert "Next listener update at 2025-10-01 10:15:00+02:00" in caplog.text + caplog.clear() with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -64,10 +65,10 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 0 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.63858" + assert state.state == "0.95013" - assert "Next data update at 2025-10-01 11:00:00+00:00" in caplog.text - assert "Next listener update at 2025-10-01 10:30:00+00:00" in caplog.text + assert "Next data update at 2025-10-01 11:00:00+02:00" not in caplog.text + assert "Next listener update at 2025-10-01 10:30:00+02:00" in caplog.text with ( patch( @@ -80,7 +81,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.66068" + assert state.state == "0.72279" with ( patch( @@ -94,9 +95,10 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.68544" + assert state.state == "0.63858" assert "Authentication error" in caplog.text + caplog.clear() with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -110,7 +112,7 @@ async def test_coordinator( # Empty responses does not raise assert mock_data.call_count == 3 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.72953" + assert state.state == "0.66068" assert "Empty response" in caplog.text with ( @@ -125,7 +127,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.90294" + assert state.state == "0.68544" assert "error" in caplog.text with ( @@ -140,7 +142,7 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.16266" + assert state.state == "0.72953" assert "error" in caplog.text with ( @@ -155,14 +157,14 @@ async def test_coordinator( await hass.async_block_till_done(wait_background_tasks=True) assert mock_data.call_count == 1 state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.90004" + assert state.state == "0.90294" assert "Response error" in caplog.text freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "3.42983" + assert state.state == "1.16266" # Test manual polling hass.config_entries.async_update_entry( @@ -173,14 +175,14 @@ async def test_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.42403" + assert state.state == "1.90004" # Prices should update without any polling made (read from cache) freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.1358" + assert state.state == "3.42983" # Test manually updating the data with ( @@ -201,7 +203,7 @@ async def test_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.933" + assert state.state == "1.42403" hass.config_entries.async_update_entry( entry=config_entry, pref_disable_polling=False From 7a6363f5cb87e120b2e98b50b02b7a8078540500 Mon Sep 17 00:00:00 2001 From: jameson_uk <1040621+jamesonuk@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:38:16 +0100 Subject: [PATCH 179/707] Ensure http2 stream cleaned up on shudown for Alexa Devices (#175766) --- .../components/alexa_devices/__init__.py | 14 ++++++++++--- tests/components/alexa_devices/test_init.py | 21 ++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index cdbb561ceb77..71e6a83702ff 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -1,7 +1,7 @@ """Alexa Devices integration.""" -from homeassistant.const import CONF_COUNTRY, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_COUNTRY, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 @@ -56,7 +56,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo on_reauth_required=_on_http2_reauth_required, ) - entry.async_on_unload(coordinator.api.stop_http2_processing) + async def _async_stop_http2(_event: Event | None = None) -> None: + """Stop HTTP/2 processing on entry unload or HA shutdown.""" + await coordinator.api.stop_http2_processing() + + entry.async_on_unload(_async_stop_http2) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_http2) + ) entry.runtime_data = coordinator diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index dff813529d26..e798b3ceeb01 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -12,7 +12,12 @@ from homeassistant.components.alexa_devices.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -205,3 +210,17 @@ async def test_http2_stop_processing_called_on_unload( await hass.async_block_till_done() mock_amazon_devices_client.stop_http2_processing.assert_awaited_once() + + +async def test_http2_stop_processing_called_on_shutdown( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test stop_http2_processing is awaited when Home Assistant stops.""" + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_amazon_devices_client.stop_http2_processing.assert_awaited_once() From 7a0f3124f815cdf9a452f07761391e27153d82e0 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 7 Jul 2026 23:46:13 +1000 Subject: [PATCH 180/707] Fix lexicographic firmware version comparison in teslemetry (#175745) --- homeassistant/components/teslemetry/binary_sensor.py | 3 ++- homeassistant/components/teslemetry/climate.py | 5 +++-- homeassistant/components/teslemetry/cover.py | 9 +++++---- homeassistant/components/teslemetry/device_tracker.py | 5 ++++- homeassistant/components/teslemetry/lock.py | 5 +++-- homeassistant/components/teslemetry/media_player.py | 3 ++- homeassistant/components/teslemetry/number.py | 3 ++- homeassistant/components/teslemetry/select.py | 3 ++- homeassistant/components/teslemetry/sensor.py | 8 ++++---- homeassistant/components/teslemetry/switch.py | 5 ++++- homeassistant/components/teslemetry/update.py | 3 ++- 11 files changed, 33 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 05a92944e65e..e64af7b6e078 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast, override +from tesla_fleet_api import firmware_at_least from teslemetry_stream.vehicle import TeslemetryStreamVehicle from homeassistant.components.binary_sensor import ( @@ -557,7 +558,7 @@ async def async_setup_entry( if ( not vehicle.poll and description.streaming_listener - and vehicle.firmware >= description.streaming_firmware + and firmware_at_least(vehicle.firmware, description.streaming_firmware) ): entities.append( TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 5e3bddb0b872..8872ad174536 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -3,6 +3,7 @@ from itertools import chain from typing import Any, cast, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope from tesla_fleet_api.teslemetry import Vehicle @@ -65,7 +66,7 @@ async def async_setup_entry( TeslemetryVehiclePollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) @@ -75,7 +76,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 55340c92e416..154ae3b47794 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -3,6 +3,7 @@ from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import Signal @@ -43,7 +44,7 @@ async def async_setup_entry( chain( ( TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ), @@ -51,7 +52,7 @@ async def async_setup_entry( TeslemetryVehiclePollingChargePortEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes ) @@ -61,7 +62,7 @@ async def async_setup_entry( TeslemetryVehiclePollingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -71,7 +72,7 @@ async def async_setup_entry( TeslemetryVehiclePollingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 4dfeec2776f1..4e8c360080f1 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from teslemetry_stream import TeslemetryStreamVehicle from teslemetry_stream.const import TeslaLocation @@ -78,7 +79,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in DESCRIPTIONS: - if vehicle.poll or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or not firmware_at_least( + vehicle.firmware, description.streaming_firmware + ): if description.polling_prefix: entities.append( TeslemetryVehiclePollingDeviceTrackerEntity( diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index c86220f45202..cf578db2ff95 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -3,6 +3,7 @@ from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle @@ -40,7 +41,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) @@ -50,7 +51,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 561ae8251835..dbe684963022 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -2,6 +2,7 @@ from typing import override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle @@ -53,7 +54,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) - if vehicle.poll or vehicle.firmware < "2025.2.6" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2025.2.6") else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 88143c3e5d0c..1588bbe83e78 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -142,7 +143,7 @@ async def async_setup_entry( description, entry.runtime_data.scopes, ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingNumberEntity( vehicle, description, diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 36aed04573f7..f6bba223b7cf 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -220,7 +221,7 @@ async def async_setup_entry( vehicle, description, entry.runtime_data.scopes ) if vehicle.poll - or vehicle.firmware < "2024.26" + or not firmware_at_least(vehicle.firmware, "2024.26") or description.streaming_listener is None else TeslemetryStreamingSelectEntity( vehicle, description, entry.runtime_data.scopes diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index d35c30863537..070f87c7848e 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, override +from tesla_fleet_api import firmware_at_least from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.components.sensor import ( @@ -1605,16 +1606,15 @@ async def async_setup_entry( if ( not vehicle.poll and description.streaming_listener - and vehicle.firmware >= description.streaming_firmware + and firmware_at_least(vehicle.firmware, description.streaming_firmware) ): entities.append(TeslemetryStreamSensorEntity(vehicle, description)) elif description.polling: entities.append(TeslemetryVehicleSensorEntity(vehicle, description)) for time_description in VEHICLE_TIME_DESCRIPTIONS: - if ( - not vehicle.poll - and vehicle.firmware >= time_description.streaming_firmware + if not vehicle.poll and firmware_at_least( + vehicle.firmware, time_description.streaming_firmware ): entities.append( TeslemetryStreamTimeSensorEntity(vehicle, time_description) diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index a9ac7a0ec3ea..05b3ea75c696 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import AutoSeat, Scope from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -162,7 +163,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: - if vehicle.poll or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or not firmware_at_least( + vehicle.firmware, description.streaming_firmware + ): if description.polling: entities.append( TeslemetryVehiclePollingVehicleSwitchEntity( diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 2a61df1fd095..7297e6cfc108 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -2,6 +2,7 @@ from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle @@ -37,7 +38,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) From 9004f3a89aa774134f608556c54fe1c62c9551db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 7 Jul 2026 14:49:00 +0100 Subject: [PATCH 181/707] Add get_prices_for_date service action to OMIE (#175820) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- homeassistant/components/omie/__init__.py | 12 + homeassistant/components/omie/coordinator.py | 4 +- homeassistant/components/omie/icons.json | 7 + .../components/omie/quality_scale.yaml | 12 +- homeassistant/components/omie/services.py | 119 + homeassistant/components/omie/services.yaml | 18 + homeassistant/components/omie/strings.json | 38 + tests/components/omie/__init__.py | 13 + tests/components/omie/conftest.py | 70 + .../omie/snapshots/test_services.ambr | 1941 +++++++++++++++++ tests/components/omie/test_services.py | 214 ++ 11 files changed, 2437 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/omie/icons.json create mode 100644 homeassistant/components/omie/services.py create mode 100644 homeassistant/components/omie/services.yaml create mode 100644 tests/components/omie/snapshots/test_services.ambr create mode 100644 tests/components/omie/test_services.py diff --git a/homeassistant/components/omie/__init__.py b/homeassistant/components/omie/__init__.py index a0e1334ff4c9..e4bade0040f5 100644 --- a/homeassistant/components/omie/__init__.py +++ b/homeassistant/components/omie/__init__.py @@ -2,11 +2,23 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import OMIEConfigEntry, OMIECoordinator +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the OMIE integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: OMIEConfigEntry) -> bool: """Set up from a config entry.""" diff --git a/homeassistant/components/omie/coordinator.py b/homeassistant/components/omie/coordinator.py index 0538e42fcd8e..a69731662bd1 100644 --- a/homeassistant/components/omie/coordinator.py +++ b/homeassistant/components/omie/coordinator.py @@ -47,7 +47,7 @@ class OMIECoordinator(DataUpdateCoordinator[OMIEResults[SpotData]]): if self.data and self.data.market_date == cet_today: data = self.data else: - data = await self._spot_price(cet_today) + data = await self.async_get_spot_price(cet_today) self._set_update_interval() return data @@ -58,7 +58,7 @@ class OMIECoordinator(DataUpdateCoordinator[OMIEResults[SpotData]]): self.update_interval = calc_update_interval(now) _LOGGER.debug("Next refresh at %s", (now + self.update_interval).isoformat()) - async def _spot_price(self, date: dt.date) -> OMIEResults[SpotData]: + async def async_get_spot_price(self, date: dt.date) -> OMIEResults[SpotData]: """Fetch OMIE spot price data for the given date.""" _LOGGER.debug("Fetching OMIE spot data for %s", date) return await pyomie.spot_price(self._client_session, date) diff --git a/homeassistant/components/omie/icons.json b/homeassistant/components/omie/icons.json new file mode 100644 index 000000000000..460e22df9c06 --- /dev/null +++ b/homeassistant/components/omie/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_prices_for_date": { + "service": "mdi:cash-multiple" + } + } +} diff --git a/homeassistant/components/omie/quality_scale.yaml b/homeassistant/components/omie/quality_scale.yaml index 9b54a27a394a..e9eec6e01950 100644 --- a/homeassistant/components/omie/quality_scale.yaml +++ b/homeassistant/components/omie/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: No custom service actions are defined. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: No custom service actions are defined. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -35,9 +31,7 @@ rules: Coordinators handle any connection issues gracefully during runtime. unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: No custom service actions are defined. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/omie/services.py b/homeassistant/components/omie/services.py new file mode 100644 index 000000000000..9dc3eab60a37 --- /dev/null +++ b/homeassistant/components/omie/services.py @@ -0,0 +1,119 @@ +"""Services for the OMIE - Spain and Portugal electricity prices integration.""" + +import datetime as dt +from enum import StrEnum +from typing import Final + +import aiohttp +from pyomie import QUARTER_HOURLY_START_DATE +import voluptuous as vol + +from homeassistant.const import ATTR_DATE +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import OMIEConfigEntry +from .util import CET, pick_series_cet + +ATTR_COUNTRIES: Final = "countries" + + +class Country(StrEnum): + """Country to retrieve prices for.""" + + ES = "es" + PT = "pt" + + +SERVICE_GET_PRICES_FOR_DATE: Final = "get_prices_for_date" +SERVICE_GET_PRICES_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_DATE): cv.date, + vol.Required(ATTR_COUNTRIES, default=[Country.ES, Country.PT]): vol.All( + cv.ensure_list, [vol.Coerce(Country)] + ), + } +) + +_QUARTER_HOUR: Final = dt.timedelta(minutes=15) +_SERIES_BY_COUNTRY: Final = {Country.ES: "es_spot_price", Country.PT: "pt_spot_price"} + + +async def _get_prices_for_date(call: ServiceCall) -> ServiceResponse: + """Get OMIE spot prices for a specific date.""" + loaded_entries: list[OMIEConfigEntry] = ( + call.hass.config_entries.async_loaded_entries(DOMAIN) + ) + if not loaded_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + entry = loaded_entries[0] + market_date: dt.date = call.data[ATTR_DATE] + countries: list[Country] = call.data[ATTR_COUNTRIES] + + if market_date < QUARTER_HOURLY_START_DATE: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="date_before_market_start", + translation_placeholders={ + "date": market_date.isoformat(), + "start_date": QUARTER_HOURLY_START_DATE.isoformat(), + }, + ) + + try: + results = await entry.runtime_data.async_get_spot_price(market_date) + except aiohttp.ClientResponseError as err: + if err.status == 404: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="data_not_available", + translation_placeholders={"date": market_date.isoformat()}, + ) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except (aiohttp.ClientError, TimeoutError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + return { + country.value: [ + { + "start": start.isoformat(), + # Add in UTC otherwise the end will be wrong across DST changes + "end": (start.astimezone(dt.UTC) + _QUARTER_HOUR) + .astimezone(CET) + .isoformat(), + "price": price_mwh / 1000, + } + for start, price_mwh in pick_series_cet(results, series_name).items() + ] + for country, series_name in _SERIES_BY_COUNTRY.items() + if country in countries + } + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the OMIE integration.""" + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + _get_prices_for_date, + schema=SERVICE_GET_PRICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/omie/services.yaml b/homeassistant/components/omie/services.yaml new file mode 100644 index 000000000000..b862e194bfa6 --- /dev/null +++ b/homeassistant/components/omie/services.yaml @@ -0,0 +1,18 @@ +get_prices_for_date: + fields: + date: + required: true + selector: + date: + countries: + required: true + default: + - es + - pt + selector: + select: + multiple: true + options: + - es + - pt + translation_key: country diff --git a/homeassistant/components/omie/strings.json b/homeassistant/components/omie/strings.json index fe6e2f85e5bc..1646d7a856b8 100644 --- a/homeassistant/components/omie/strings.json +++ b/homeassistant/components/omie/strings.json @@ -21,5 +21,43 @@ "name": "Portugal spot price" } } + }, + "exceptions": { + "cannot_connect": { + "message": "Error connecting to the OMIE API." + }, + "data_not_available": { + "message": "Prices for {date} have not been published yet." + }, + "date_before_market_start": { + "message": "No quarter-hourly prices exist for {date}. Prices are available from {start_date} onwards." + }, + "entry_not_loaded": { + "message": "The OMIE integration is not loaded." + } + }, + "selector": { + "country": { + "options": { + "es": "Spain", + "pt": "Portugal" + } + } + }, + "services": { + "get_prices_for_date": { + "description": "Retrieves the electricity spot prices (in €/kWh) for a specific date. Prices for the next day are published daily at around 13:30 CET.", + "fields": { + "countries": { + "description": "The countries to get the prices for.", + "name": "Countries" + }, + "date": { + "description": "The date to get the prices for.", + "name": "Date" + } + }, + "name": "Get prices for date" + } } } diff --git a/tests/components/omie/__init__.py b/tests/components/omie/__init__.py index f84145810296..b07d40d8df39 100644 --- a/tests/components/omie/__init__.py +++ b/tests/components/omie/__init__.py @@ -4,6 +4,19 @@ from datetime import date from pyomie.model import OMIEResults +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the OMIE integration.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + def price_enc(country: int, day: int, hour: int, minute: int) -> float: """Encode the given data into a price. diff --git a/tests/components/omie/conftest.py b/tests/components/omie/conftest.py index 5c722fd01077..d13ececb629e 100644 --- a/tests/components/omie/conftest.py +++ b/tests/components/omie/conftest.py @@ -92,6 +92,76 @@ def mock_omie_results_jan15() -> OMIEResults: ) +@pytest.fixture +def mock_omie_results_oct15() -> OMIEResults: + """Return mock OMIEResults for 2025-10-15.""" + test_date = dt.date(2025, 10, 15) + spot_data = SpotData( + url="https://example.com?date=2025-10-15", + market_date=test_date.isoformat(), + header="Test Data Oct 15", + es_pt_total_power=[], + es_purchases_power=[], + pt_purchases_power=[], + es_sales_power=[], + pt_sales_power=[], + es_pt_power=[], + es_to_pt_exports_power=[], + es_from_pt_imports_power=[], + es_spot_price=[ + price_enc(country=34, day=15, hour=h, minute=m) + for h in range(24) + for m in (0, 15, 30, 45) + ], + pt_spot_price=[ + price_enc(country=351, day=15, hour=h, minute=m) + for h in range(24) + for m in (0, 15, 30, 45) + ], + ) + return OMIEResults( + updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + market_date=test_date, + contents=spot_data, + raw=json.dumps(spot_data), + ) + + +@pytest.fixture +def mock_omie_results_oct26_dst() -> OMIEResults: + """Return mock OMIEResults for 2025-10-26, the 25-hour CET DST fall-back day.""" + test_date = dt.date(2025, 10, 26) + spot_data = SpotData( + url="https://example.com?date=2025-10-26", + market_date=test_date.isoformat(), + header="Test Data Oct 26", + es_pt_total_power=[], + es_purchases_power=[], + pt_purchases_power=[], + es_sales_power=[], + pt_sales_power=[], + es_pt_power=[], + es_to_pt_exports_power=[], + es_from_pt_imports_power=[], + es_spot_price=[ + price_enc(country=34, day=26, hour=h, minute=m) + for h in range(25) + for m in (0, 15, 30, 45) + ], + pt_spot_price=[ + price_enc(country=351, day=26, hour=h, minute=m) + for h in range(25) + for m in (0, 15, 30, 45) + ], + ) + return OMIEResults( + updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + market_date=test_date, + contents=spot_data, + raw=json.dumps(spot_data), + ) + + @pytest.fixture def mock_omie_results_jan16() -> OMIEResults: """Return mock OMIEResults for 2024-01-16.""" diff --git a/tests/components/omie/snapshots/test_services.ambr b/tests/components/omie/snapshots/test_services.ambr new file mode 100644 index 000000000000..d8768b762e97 --- /dev/null +++ b/tests/components/omie/snapshots/test_services.ambr @@ -0,0 +1,1941 @@ +# serializer version: 1 +# name: test_get_prices_for_date[both] + dict({ + 'es': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 34150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 34150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 34150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 34150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 34150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 34150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 34150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 34150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 34150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 34150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 34150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 34150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 34150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 34150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 34150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 34150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 34150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 34150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 34150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 34150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 34150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 34150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 34150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 34150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 34150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 34150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 34150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 34150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 34150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 34150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 34150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 34150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 34150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 34150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 34150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 34150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 34150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 34150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 34150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 34150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 34151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 34151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 34151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 34151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 34151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 34151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 34151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 34151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 34151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 34151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 34151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 34151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 34151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 34151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 34151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 34151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 34151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 34151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 34151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 34151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 34151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 34151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 34151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 34151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 34151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 34151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 34151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 34151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 34151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 34151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 34151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 34151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 34151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 34151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 34151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 34151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 34151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 34151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 34151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 34151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 34152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 34152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 34152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 34152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 34152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 34152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 34152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 34152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 34152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 34152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 34152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 34152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 34152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 34152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 34152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 34152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + 'pt': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 351150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 351150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 351150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 351150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 351150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 351150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 351150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 351150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 351150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 351150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 351150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 351150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 351150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 351150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 351150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 351150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 351150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 351150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 351150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 351150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 351150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 351150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 351150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 351150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 351150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 351150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 351150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 351150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 351150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 351150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 351150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 351150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 351150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 351150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 351150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 351150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 351150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 351150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 351150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 351150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 351151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 351151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 351151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 351151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 351151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 351151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 351151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 351151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 351151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 351151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 351151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 351151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 351151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 351151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 351151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 351151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 351151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 351151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 351151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 351151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 351151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 351151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 351151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 351151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 351151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 351151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 351151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 351151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 351151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 351151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 351151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 351151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 351151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 351151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 351151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 351151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 351151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 351151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 351151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 351151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 351152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 351152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 351152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 351152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 351152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 351152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 351152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 351152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 351152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 351152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 351152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 351152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 351152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 351152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 351152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 351152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + }) +# --- +# name: test_get_prices_for_date[portugal] + dict({ + 'pt': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 351150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 351150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 351150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 351150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 351150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 351150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 351150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 351150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 351150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 351150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 351150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 351150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 351150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 351150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 351150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 351150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 351150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 351150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 351150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 351150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 351150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 351150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 351150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 351150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 351150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 351150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 351150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 351150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 351150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 351150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 351150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 351150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 351150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 351150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 351150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 351150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 351150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 351150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 351150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 351150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 351151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 351151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 351151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 351151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 351151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 351151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 351151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 351151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 351151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 351151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 351151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 351151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 351151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 351151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 351151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 351151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 351151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 351151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 351151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 351151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 351151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 351151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 351151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 351151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 351151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 351151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 351151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 351151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 351151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 351151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 351151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 351151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 351151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 351151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 351151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 351151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 351151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 351151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 351151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 351151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 351152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 351152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 351152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 351152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 351152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 351152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 351152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 351152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 351152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 351152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 351152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 351152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 351152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 351152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 351152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 351152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + }) +# --- +# name: test_get_prices_for_date[spain] + dict({ + 'es': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 34150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 34150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 34150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 34150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 34150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 34150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 34150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 34150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 34150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 34150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 34150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 34150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 34150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 34150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 34150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 34150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 34150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 34150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 34150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 34150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 34150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 34150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 34150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 34150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 34150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 34150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 34150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 34150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 34150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 34150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 34150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 34150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 34150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 34150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 34150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 34150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 34150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 34150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 34150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 34150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 34151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 34151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 34151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 34151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 34151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 34151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 34151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 34151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 34151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 34151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 34151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 34151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 34151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 34151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 34151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 34151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 34151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 34151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 34151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 34151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 34151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 34151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 34151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 34151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 34151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 34151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 34151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 34151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 34151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 34151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 34151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 34151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 34151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 34151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 34151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 34151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 34151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 34151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 34151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 34151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 34152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 34152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 34152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 34152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 34152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 34152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 34152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 34152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 34152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 34152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 34152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 34152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 34152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 34152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 34152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 34152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + }) +# --- diff --git a/tests/components/omie/test_services.py b/tests/components/omie/test_services.py new file mode 100644 index 000000000000..ff15a58cce6c --- /dev/null +++ b/tests/components/omie/test_services.py @@ -0,0 +1,214 @@ +"""Test the OMIE - Spain and Portugal electricity prices services.""" + +import datetime as dt +from unittest.mock import MagicMock + +import aiohttp +from pyomie.model import OMIEResults +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.omie.const import DOMAIN +from homeassistant.components.omie.services import ( + ATTR_COUNTRIES, + SERVICE_GET_PRICES_FOR_DATE, +) +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import setup_integration, spot_price_fetcher + +from tests.common import MockConfigEntry + +TEST_DATE = "2025-10-15" +TEST_DST_DATE = "2025-10-26" + + +@pytest.mark.parametrize( + "countries", + [ + pytest.param(["es"], id="spain"), + pytest.param(["pt"], id="portugal"), + pytest.param(["es", "pt"], id="both"), + ], +) +async def test_get_prices_for_date( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + mock_omie_results_oct15: OMIEResults, + countries: list[str], + snapshot: SnapshotAssertion, +) -> None: + """Test the get_prices_for_date service response.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = spot_price_fetcher( + {TEST_DATE: mock_omie_results_oct15} + ) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE, ATTR_COUNTRIES: countries}, + blocking=True, + return_response=True, + ) + + assert response == snapshot + + +async def test_get_prices_for_date_default_country( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + mock_omie_results_oct15: OMIEResults, +) -> None: + """Test the get_prices_for_date service returns both countries by default.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = spot_price_fetcher( + {TEST_DATE: mock_omie_results_oct15} + ) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE}, + blocking=True, + return_response=True, + ) + + assert set(response) == {"es", "pt"} + + +async def test_get_prices_for_date_dst_fall_back( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + mock_omie_results_oct26_dst: OMIEResults, +) -> None: + """Test interval ends on the 25-hour DST fall-back day.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = spot_price_fetcher( + {TEST_DST_DATE: mock_omie_results_oct26_dst} + ) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DST_DATE, ATTR_COUNTRIES: ["es"]}, + blocking=True, + return_response=True, + ) + + intervals = response["es"] + assert len(intervals) == 100 + for interval in intervals: + start = dt.datetime.fromisoformat(interval["start"]) + end = dt.datetime.fromisoformat(interval["end"]) + assert end - start == dt.timedelta(minutes=15) + + last_cest_quarter = next( + interval + for interval in intervals + if interval["start"] == "2025-10-26T02:45:00+02:00" + ) + assert last_cest_quarter["end"] == "2025-10-26T02:00:00+01:00" + + +@pytest.mark.parametrize( + ("side_effect", "expected_exception", "expected_translation_key"), + [ + pytest.param( + aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=404 + ), + ServiceValidationError, + "data_not_available", + id="not_published_yet", + ), + pytest.param( + aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=500 + ), + HomeAssistantError, + "cannot_connect", + id="server_error", + ), + pytest.param( + aiohttp.ClientError("Connection error"), + HomeAssistantError, + "cannot_connect", + id="client_error", + ), + pytest.param( + TimeoutError(), + HomeAssistantError, + "cannot_connect", + id="timeout", + ), + ], +) +async def test_get_prices_for_date_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + side_effect: Exception, + expected_exception: type[HomeAssistantError], + expected_translation_key: str, +) -> None: + """Test the get_prices_for_date service error handling.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = side_effect + + with pytest.raises(expected_exception) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE}, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == expected_translation_key + + +@pytest.mark.usefixtures("mock_pyomie") +async def test_get_prices_for_date_before_market_start( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_prices_for_date service with a date before the market start.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: "2024-01-15"}, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == "date_before_market_start" + + +@pytest.mark.usefixtures("mock_pyomie") +async def test_get_prices_for_date_unloaded_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_prices_for_date service with no loaded config entry.""" + await setup_integration(hass, mock_config_entry) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE}, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == "entry_not_loaded" From da68bf36a4b1d4f58f0fe897cbecd6eb7430b08b Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:55:09 +0200 Subject: [PATCH 182/707] Add dynamic device support to Vistapool (#172912) Co-authored-by: Claude --- .../components/vistapool/__init__.py | 137 ++++++++++++++++-- .../components/vistapool/binary_sensor.py | 89 +++++++----- homeassistant/components/vistapool/button.py | 31 +++- homeassistant/components/vistapool/const.py | 2 + homeassistant/components/vistapool/light.py | 15 +- homeassistant/components/vistapool/number.py | 94 +++++++----- .../components/vistapool/quality_scale.yaml | 2 +- homeassistant/components/vistapool/select.py | 46 ++++-- homeassistant/components/vistapool/sensor.py | 75 ++++++---- tests/components/vistapool/test_init.py | 99 ++++++++++++- 10 files changed, 450 insertions(+), 140 deletions(-) diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 42dc69b4361d..d9b608897f93 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -1,21 +1,29 @@ """The Vistapool integration.""" +import asyncio from dataclasses import dataclass, field import logging -from aioaquarite import AquariteAuth, AquariteClient, AquariteError, AuthenticationError +from aioaquarite import ( + AquariteAuth, + AquariteClient, + AquariteError, + AuthenticationError, + ResilientUserPoolsSubscription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -39,6 +47,7 @@ class VistapoolData: coordinators: dict[str, VistapoolDataUpdateCoordinator] = field( default_factory=dict ) + sync_lock: asyncio.Lock = field(default_factory=asyncio.Lock) type VistapoolConfigEntry = ConfigEntry[VistapoolData] @@ -77,29 +86,127 @@ async def async_setup_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> ) data = VistapoolData(auth=auth, api=api) + entry.runtime_data = data try: for pool_id, pool_name in pools.items(): - coordinator = VistapoolDataUpdateCoordinator( - hass, entry, auth, api, pool_id, pool_name - ) - data.coordinators[pool_id] = coordinator - await coordinator.async_config_entry_first_refresh() - try: - await coordinator.subscribe() - except AquariteError as exc: - raise ConfigEntryNotReady from exc - entry.async_on_unload(coordinator.async_shutdown) + await _async_add_coordinator(hass, entry, pool_id, pool_name, first=True) except Exception: for coordinator in data.coordinators.values(): await coordinator.async_shutdown() raise - entry.runtime_data = data + def _on_user_pools_snapshot(pool_ids: list[str]) -> None: + """Bridge the Firestore snapshot from the watch thread to the HA loop.""" + hass.loop.call_soon_threadsafe(_schedule_reconcile, pool_ids) + + @callback + def _schedule_reconcile(pool_ids: list[str]) -> None: + entry.async_create_background_task( + hass, + _async_reconcile_pools(hass, entry, pool_ids), + name=f"vistapool_reconcile_{entry.entry_id}", + ) + + # Subscribe before forwarding platforms so a failed subscribe doesn't leave + # platforms set up; on retry they would re-forward and raise "already setup". + try: + subscription: ResilientUserPoolsSubscription = ( + await api.subscribe_user_pools_resilient(_on_user_pools_snapshot) + ) + except AquariteError as exc: + for coordinator in data.coordinators.values(): + await coordinator.async_shutdown() + raise ConfigEntryNotReady from exc + entry.async_on_unload(subscription.aclose) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> bool: """Unload Vistapool config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + # Hold sync_lock so a reconcile background task can't mutate the + # coordinators dict while we shut it down. + async with entry.runtime_data.sync_lock: + for coordinator in entry.runtime_data.coordinators.values(): + await coordinator.async_shutdown() + return unload_ok + + +async def _async_initial_refresh( + coordinator: VistapoolDataUpdateCoordinator, *, first: bool +) -> None: + """Populate coordinator data for a pool; raise if it would stay empty.""" + if first: + await coordinator.async_config_entry_first_refresh() + return + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_failed", + ) + + +async def _async_add_coordinator( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + pool_id: str, + pool_name: str, + *, + first: bool, +) -> VistapoolDataUpdateCoordinator: + """Create, refresh and subscribe a coordinator for a single pool.""" + coordinator = VistapoolDataUpdateCoordinator( + hass, entry, entry.runtime_data.auth, entry.runtime_data.api, pool_id, pool_name + ) + try: + await _async_initial_refresh(coordinator, first=first) + try: + await coordinator.subscribe() + except AquariteError as exc: + raise ConfigEntryNotReady from exc + except ConfigEntryNotReady: + await coordinator.async_shutdown() + raise + entry.runtime_data.coordinators[pool_id] = coordinator + return coordinator + + +async def _async_reconcile_pools( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + pool_ids: list[str], +) -> None: + """Reconcile the runtime coordinator set against a fresh pool ID list.""" + async with entry.runtime_data.sync_lock: + current = set(entry.runtime_data.coordinators) + fetched = set(pool_ids) + if current == fetched: + return + + new_ids = fetched - current + names: dict[str, str] = {} + if new_ids: + try: + names = await entry.runtime_data.api.get_pools() + except AquariteError as err: + _LOGGER.debug("Pool name lookup failed during reconcile: %s", err) + new_ids = set() + + for pool_id in new_ids: + if pool_id not in names: + continue + try: + coordinator = await _async_add_coordinator( + hass, entry, pool_id, names[pool_id], first=False + ) + except ConfigEntryNotReady as err: + _LOGGER.warning("Failed to add new pool %s: %s", pool_id, err) + continue + async_dispatcher_send( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", coordinator + ) diff --git a/homeassistant/components/vistapool/binary_sensor.py b/homeassistant/components/vistapool/binary_sensor.py index 864c6f89d4f1..dc72edd624e3 100644 --- a/homeassistant/components/vistapool/binary_sensor.py +++ b/homeassistant/components/vistapool/binary_sensor.py @@ -9,7 +9,8 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry @@ -20,6 +21,7 @@ from .const import ( PATH_HASIO, PATH_HASPH, PATH_HASRX, + SIGNAL_NEW_POOL, ) from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -162,6 +164,46 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[VistapoolBinarySensorEntityDescription, ...] = ) +def _build_binary_sensor_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[BinarySensorEntity]: + """Build the binary sensor entities for a single pool.""" + entities: list[BinarySensorEntity] = [] + for description in BINARY_SENSOR_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolBinarySensor(coordinator, description)) + + if coordinator.get_value(PATH_HASHIDRO): + is_electrolysis = coordinator.get_value("hidro.is_electrolysis") + entities.append( + VistapoolBinarySensor( + coordinator, + VistapoolBinarySensorEntityDescription( + key="electrolysis_low" if is_electrolysis else "hydrolysis_low", + translation_key=( + "electrolysis_low" if is_electrolysis else "hydrolysis_low" + ), + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.low", + ), + ) + ) + + if any( + coordinator.get_value(path) + for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX) + ): + entities.append(VistapoolDosingTankBinarySensor(coordinator)) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -169,43 +211,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool binary sensors for every pool on the account.""" entities: list[BinarySensorEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in BINARY_SENSOR_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue - entities.append(VistapoolBinarySensor(coordinator, description)) - - if coordinator.get_value(PATH_HASHIDRO): - is_electrolysis = coordinator.get_value("hidro.is_electrolysis") - entities.append( - VistapoolBinarySensor( - coordinator, - VistapoolBinarySensorEntityDescription( - key="electrolysis_low" if is_electrolysis else "hydrolysis_low", - translation_key=( - "electrolysis_low" if is_electrolysis else "hydrolysis_low" - ), - device_class=BinarySensorDeviceClass.PROBLEM, - value_path="hidro.low", - ), - ) - ) - - if any( - coordinator.get_value(path) - for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX) - ): - entities.append(VistapoolDosingTankBinarySensor(coordinator)) - + entities.extend(_build_binary_sensor_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_binary_sensor_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolBinarySensor(VistapoolEntity, BinarySensorEntity): """Generic Vistapool binary sensor driven by an entity description.""" diff --git a/homeassistant/components/vistapool/button.py b/homeassistant/components/vistapool/button.py index bfc825807b97..0524f84226d1 100644 --- a/homeassistant/components/vistapool/button.py +++ b/homeassistant/components/vistapool/button.py @@ -6,12 +6,13 @@ from typing import override from aioaquarite import AquariteError from homeassistant.components.button import ButtonEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -22,16 +23,34 @@ _LIGHT_STATUS_PATH = "light.status" _LED_PULSE_DELAY_SECONDS = 1.0 +def _build_button_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[VistapoolLEDPulseButton]: + """Build the button entities for a single pool.""" + if not coordinator.get_value(_HASLED_PATH): + return [] + return [VistapoolLEDPulseButton(coordinator)] + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vistapool buttons for every pool that has an LED fixture.""" - async_add_entities( - VistapoolLEDPulseButton(coordinator) - for coordinator in entry.runtime_data.coordinators.values() - if coordinator.get_value(_HASLED_PATH) + entities: list[VistapoolLEDPulseButton] = [] + for coordinator in entry.runtime_data.coordinators.values(): + entities.extend(_build_button_entities(coordinator)) + async_add_entities(entities) + + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_button_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) ) diff --git a/homeassistant/components/vistapool/const.py b/homeassistant/components/vistapool/const.py index f7e30b95aaa9..f2897f2f86d4 100644 --- a/homeassistant/components/vistapool/const.py +++ b/homeassistant/components/vistapool/const.py @@ -12,3 +12,5 @@ PATH_HASPH = f"{PATH_PREFIX}hasPH" PATH_HASRX = f"{PATH_PREFIX}hasRX" PATH_HASUV = f"{PATH_PREFIX}hasUV" PATH_HASHIDRO = f"{PATH_PREFIX}hasHidro" + +SIGNAL_NEW_POOL = f"{DOMAIN}_new_pool" diff --git a/homeassistant/components/vistapool/light.py b/homeassistant/components/vistapool/light.py index f4d05a23db19..6967a233d5dd 100644 --- a/homeassistant/components/vistapool/light.py +++ b/homeassistant/components/vistapool/light.py @@ -5,12 +5,13 @@ from typing import Any, override from aioaquarite import AquariteError from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -30,6 +31,16 @@ async def async_setup_entry( for coordinator in entry.runtime_data.coordinators.values() ) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities([VistapoolLight(coordinator)]) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolLight(VistapoolEntity, LightEntity): """Representation of a Vistapool pool light.""" diff --git a/homeassistant/components/vistapool/number.py b/homeassistant/components/vistapool/number.py index 2c8572ccec41..0e4fa5787625 100644 --- a/homeassistant/components/vistapool/number.py +++ b/homeassistant/components/vistapool/number.py @@ -16,12 +16,13 @@ from homeassistant.const import ( UnitOfElectricPotential, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry -from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX +from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -138,6 +139,48 @@ NUMBER_DESCRIPTIONS: tuple[VistapoolNumberEntityDescription, ...] = ( ) +def _build_number_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[NumberEntity]: + """Build the number entities for a single pool.""" + entities: list[NumberEntity] = [] + for description in NUMBER_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolNumber(coordinator, description)) + + if coordinator.get_value(PATH_HASHIDRO): + key = ( + "hydrolysis_setpoint" + if coordinator.get_value("hidro.is_electrolysis") is False + else "electrolysis_setpoint" + ) + entities.append( + VistapoolNumber( + coordinator, + VistapoolNumberEntityDescription( + key=key, + translation_key=key, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=50.0, + native_step=0.1, + native_unit_of_measurement="g/h", + value_path="hidro.level", + scale=10, + max_value_fn=_max_electrolysis, + ), + ) + ) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -145,45 +188,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool number entities for every pool on the account.""" entities: list[NumberEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in NUMBER_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue - entities.append(VistapoolNumber(coordinator, description)) - - if coordinator.get_value(PATH_HASHIDRO): - key = ( - "hydrolysis_setpoint" - if coordinator.get_value("hidro.is_electrolysis") is False - else "electrolysis_setpoint" - ) - entities.append( - VistapoolNumber( - coordinator, - VistapoolNumberEntityDescription( - key=key, - translation_key=key, - entity_category=EntityCategory.CONFIG, - native_min_value=0, - native_max_value=50.0, - native_step=0.1, - native_unit_of_measurement="g/h", - value_path="hidro.level", - scale=10, - max_value_fn=_max_electrolysis, - ), - ) - ) - + entities.extend(_build_number_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_number_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolNumber(VistapoolEntity, NumberEntity): """Generic Vistapool number driven by an entity description.""" diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index b1d6aae349b9..a39a93b7b22b 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -59,7 +59,7 @@ rules: docs-supported-devices: done docs-supported-functions: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-device-class: todo entity-translations: done exception-translations: done diff --git a/homeassistant/components/vistapool/select.py b/homeassistant/components/vistapool/select.py index 97e98b8429a7..8db7d4ed06ca 100644 --- a/homeassistant/components/vistapool/select.py +++ b/homeassistant/components/vistapool/select.py @@ -7,12 +7,13 @@ from aioaquarite import AquariteError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -60,6 +61,24 @@ SELECT_DESCRIPTIONS: tuple[VistapoolSelectEntityDescription, ...] = ( ) +def _build_select_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[SelectEntity]: + """Build the select entities for a single pool.""" + entities: list[SelectEntity] = [] + for description in SELECT_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolSelect(coordinator, description)) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -67,21 +86,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool select entities for every pool on the account.""" entities: list[SelectEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in SELECT_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue - entities.append(VistapoolSelect(coordinator, description)) - + entities.extend(_build_select_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_select_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + def _to_index(raw: Any) -> int | None: """Convert a coordinator value into an options-list index, or None if not possible.""" diff --git a/homeassistant/components/vistapool/sensor.py b/homeassistant/components/vistapool/sensor.py index 21bde1bdf4ce..5ecb8a37622f 100644 --- a/homeassistant/components/vistapool/sensor.py +++ b/homeassistant/components/vistapool/sensor.py @@ -17,7 +17,8 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry @@ -28,6 +29,7 @@ from .const import ( PATH_HASPH, PATH_HASRX, PATH_HASUV, + SIGNAL_NEW_POOL, ) from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -125,6 +127,39 @@ SENSOR_DESCRIPTIONS: tuple[VistapoolSensorEntityDescription, ...] = ( ) +def _build_sensor_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[VistapoolSensorEntity]: + """Build the sensor entities for a single pool.""" + entities: list[VistapoolSensorEntity] = [] + for description in SENSOR_DESCRIPTIONS: + if description.exists_path is not None and not coordinator.get_value( + description.exists_path + ): + continue + entities.append(VistapoolSensorEntity(coordinator, description)) + + # Electrolysis/hydrolysis: dynamic key based on hardware type + if coordinator.get_value(PATH_HASHIDRO): + is_electrolysis = coordinator.get_value("hidro.is_electrolysis") + entities.append( + VistapoolSensorEntity( + coordinator, + VistapoolSensorEntityDescription( + key="electrolysis" if is_electrolysis else "hydrolysis", + translation_key=( + "electrolysis" if is_electrolysis else "hydrolysis" + ), + native_unit_of_measurement="g/h", + state_class=SensorStateClass.MEASUREMENT, + value_path="hidro.current", + value_fn=_convert_tenths, + ), + ) + ) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -132,36 +167,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool sensors for every pool on the account.""" entities: list[VistapoolSensorEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in SENSOR_DESCRIPTIONS: - if description.exists_path is not None and not coordinator.get_value( - description.exists_path - ): - continue - entities.append(VistapoolSensorEntity(coordinator, description)) - - # Electrolysis/hydrolysis: dynamic key based on hardware type - if coordinator.get_value(PATH_HASHIDRO): - is_electrolysis = coordinator.get_value("hidro.is_electrolysis") - entities.append( - VistapoolSensorEntity( - coordinator, - VistapoolSensorEntityDescription( - key="electrolysis" if is_electrolysis else "hydrolysis", - translation_key=( - "electrolysis" if is_electrolysis else "hydrolysis" - ), - native_unit_of_measurement="g/h", - state_class=SensorStateClass.MEASUREMENT, - value_path="hidro.current", - value_fn=_convert_tenths, - ), - ) - ) - + entities.extend(_build_sensor_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_sensor_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolSensorEntity(VistapoolEntity, SensorEntity): """Generic Vistapool sensor driven by an entity description.""" diff --git a/tests/components/vistapool/test_init.py b/tests/components/vistapool/test_init.py index 9d8c6e9d2edb..8f66760c0c57 100644 --- a/tests/components/vistapool/test_init.py +++ b/tests/components/vistapool/test_init.py @@ -6,11 +6,18 @@ from unittest.mock import AsyncMock, MagicMock from aioaquarite import AquariteError, AuthenticationError +from homeassistant.components.vistapool.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import MOCK_POOL_ID, MOCK_POOL_NAME from tests.common import MockConfigEntry +_SECOND_POOL_ID = "ZYXWVU9876543210" +_SECOND_POOL_NAME = "Spa" + async def test_setup_entry( hass: HomeAssistant, @@ -91,7 +98,7 @@ async def test_setup_entry_subscribe_failure( mock_config_entry: MockConfigEntry, mock_vistapool_client: AsyncMock, ) -> None: - """Test setup retries when the Firestore subscription fails.""" + """Test setup retries when the per-pool Firestore subscription fails.""" mock_vistapool_client.subscribe_pool_resilient.side_effect = AquariteError( "subscribe fail" ) @@ -103,6 +110,96 @@ async def test_setup_entry_subscribe_failure( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_entry_user_pools_subscribe_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test setup retries when the user-pools Firestore subscription fails.""" + mock_vistapool_client.subscribe_user_pools_resilient.side_effect = AquariteError( + "user-pools subscribe fail" + ) + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_user_pools_snapshot_adds_new_pool( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a user-pools snapshot with a new pool creates its device and entities.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.my_pool_temperature") is not None + assert hass.states.get("sensor.spa_temperature") is None + + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID, _SECOND_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is not None + assert device_registry.async_get_device(identifiers={(DOMAIN, _SECOND_POOL_ID)}) + + +async def test_user_pools_snapshot_retries_new_pool_after_refresh_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a failed first refresh on a new pool is not orphaned and retries next snapshot.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + mock_vistapool_client.fetch_pool_data.side_effect = AquariteError("refresh failed") + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID, _SECOND_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is None + + mock_vistapool_client.fetch_pool_data.side_effect = None + mock_vistapool_client.fetch_pool_data.return_value = {} + snapshot_cb([MOCK_POOL_ID, _SECOND_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is not None + + +async def test_user_pools_snapshot_no_change_is_noop( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a snapshot matching the current set does not refetch pool names.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_vistapool_client.get_pools.reset_mock() + + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID]) + await hass.async_block_till_done() + + mock_vistapool_client.get_pools.assert_not_called() + + async def test_apply_optimistic_creates_missing_intermediate_dicts( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From b4b11fa383a05d63db9dfab0d9358b032df67bfa Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Tue, 7 Jul 2026 16:08:17 +0200 Subject: [PATCH 183/707] Migrate legacy data stores in Netatmo (#175620) --- homeassistant/components/netatmo/__init__.py | 22 +--------- homeassistant/components/netatmo/camera.py | 9 ++-- homeassistant/components/netatmo/climate.py | 8 +--- homeassistant/components/netatmo/const.py | 7 --- .../components/netatmo/data_handler.py | 19 +++++--- homeassistant/components/netatmo/entity.py | 5 +-- .../components/netatmo/media_source.py | 31 ++++++++++--- homeassistant/components/netatmo/select.py | 16 +++---- homeassistant/components/netatmo/services.py | 8 +--- homeassistant/components/netatmo/webhook.py | 44 ++++++++++++------- tests/components/netatmo/test_media_source.py | 25 +++++++---- 11 files changed, 96 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 8289b2bf0539..b1d591190ff0 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -29,16 +29,7 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from . import api -from .const import ( - DATA_CAMERAS, - DATA_DEVICE_IDS, - DATA_EVENTS, - DATA_HOMES, - DATA_PERSONS, - DATA_SCHEDULES, - DOMAIN, - PLATFORMS, -) +from .const import DOMAIN, PLATFORMS from .data_handler import NetatmoConfigEntry, NetatmoDataHandler from .services import async_setup_services from .webhook import async_register_webhook, async_unregister_webhook @@ -52,17 +43,6 @@ MAX_WEBHOOK_RETRIES = 3 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Netatmo component.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - hass.data[DOMAIN] = { - DATA_PERSONS: {}, - DATA_DEVICE_IDS: {}, - DATA_SCHEDULES: {}, - DATA_HOMES: {}, - DATA_EVENTS: {}, - DATA_CAMERAS: {}, - } - async_setup_services(hass) return True diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index b1dc17f4f5d9..51c14e0b69d3 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -1,5 +1,4 @@ """Support for the Netatmo cameras.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast, override @@ -24,8 +23,6 @@ from .const import ( CAMERA_LIGHT_MODES, CAMERA_TRIGGERS, CONF_URL_SECURITY, - DATA_CAMERAS, - DATA_EVENTS, DOMAIN, EVENT_TYPE_CONNECTION, EVENT_TYPE_DISCONNECTION, @@ -137,7 +134,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): ) ) - self.hass.data[DOMAIN][DATA_CAMERAS][self.device.entity_id] = self.device.name + self.data_handler.cameras[self.device.entity_id] = self.device.name @callback def handle_event(self, event: dict) -> None: @@ -281,8 +278,8 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self._attr_is_streaming = self.device.monitoring self._attr_motion_detection_enabled = self.device.monitoring - self.hass.data[DOMAIN][DATA_EVENTS][self.device.entity_id] = ( - self.process_events(self.device.events) + self.data_handler.events[self.device.entity_id] = self.process_events( + self.device.events ) def process_events(self, event_list: list[NaEvent]) -> dict: diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 936be63126f2..7c60ea7f2f0f 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,5 +1,4 @@ """Support for Netatmo Smart thermostats.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast, override @@ -39,7 +38,6 @@ from .const import ( ATTR_SELECTED_SCHEDULE_ID, ATTR_TARGET_TEMPERATURE, ATTR_TIME_PERIOD, - DATA_SCHEDULES, DOMAIN, EVENT_TYPE_CANCEL_SET_POINT, EVENT_TYPE_SCHEDULE, @@ -254,7 +252,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if data["event_type"] == EVENT_TYPE_SCHEDULE: # handle schedule change if "schedule_id" in data: - selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ + selected_schedule = self.data_handler.schedules[ self.home.entity_id ].get(data["schedule_id"]) self._selected_schedule = getattr( @@ -461,9 +459,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None - for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].items(): + for sid, schedule in self.data_handler.schedules[self.home.entity_id].items(): if schedule.name == schedule_name: schedule_id = sid break diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 41bfc09dc905..96bdb16b9201 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -71,13 +71,6 @@ CONF_WEATHER_AREAS = "weather_areas" OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token" -DATA_CAMERAS = "cameras" -DATA_DEVICE_IDS = "netatmo_device_ids" -DATA_EVENTS = "netatmo_events" -DATA_HOMES = "netatmo_homes" -DATA_PERSONS = "netatmo_persons" -DATA_SCHEDULES = "netatmo_schedules" - NETATMO_EVENT = "netatmo_event" DEFAULT_DISCOVERY = True diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 8d63a2016f3d..db8523bd5960 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -1,5 +1,4 @@ """The Netatmo data handler.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections import deque from dataclasses import dataclass @@ -15,6 +14,7 @@ from pyatmo.modules.device_types import ( DeviceCategory as NetatmoDeviceCategory, DeviceType as NetatmoDeviceType, ) +from pyatmo.schedule import Schedule from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry @@ -27,8 +27,6 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( CAMERA_CONNECTION_WEBHOOKS, - DATA_PERSONS, - DATA_SCHEDULES, DOMAIN, MANUFACTURER, NETATMO_CREATE_BUTTON, @@ -91,6 +89,12 @@ SCAN_INTERVAL = 60 type NetatmoConfigEntry = ConfigEntry[NetatmoDataHandler] +def async_get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry | None: + """Return the single loaded Netatmo config entry, if any.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return entries[0] if entries else None + + @dataclass class NetatmoDevice: """Netatmo device class.""" @@ -160,6 +164,11 @@ class NetatmoDataHandler: self._rate_limit = DEV_LIMIT self.poll_start = time() self.poll_count = 0 + self.persons: dict[str, dict[str, str | None]] = {} + self.schedules: dict[str, dict[str, Schedule]] = {} + self.device_ids: dict[str, str] = {} + self.cameras: dict[str, str] = {} + self.events: dict[str, dict] = {} async def async_setup(self) -> None: """Set up the Netatmo data handler.""" @@ -330,7 +339,7 @@ class NetatmoDataHandler: self.setup_rooms(home, signal_home) self.setup_modules(home, signal_home) - self.hass.data[DOMAIN][DATA_PERSONS][home.entity_id] = { + self.persons[home.entity_id] = { person.entity_id: person.pseudo for person in home.persons.values() } @@ -459,7 +468,7 @@ class NetatmoDataHandler: if NetatmoDeviceCategory.climate in [ next(iter(x)) for x in [room.features for room in home.rooms.values()] if x ]: - self.hass.data[DOMAIN][DATA_SCHEDULES][home.entity_id] = self.account.homes[ + self.schedules[home.entity_id] = self.account.homes[ home.entity_id ].schedules diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 2b2b5ea52b8c..d74658eb0727 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -16,7 +16,6 @@ from homeassistant.helpers.entity import Entity from .const import ( CONF_URL_ENERGY, CONF_URL_WEATHER, - DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME, @@ -141,9 +140,7 @@ class NetatmoRoomEntity(NetatmoDeviceEntity): if device := registry.async_get_device( identifiers={(DOMAIN, self.device.entity_id)} ): - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - self.hass.data[DOMAIN][DATA_DEVICE_IDS][self.device.entity_id] = device.id + self.data_handler.device_ids[self.device.entity_id] = device.id @property @override diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 653b82e5acc2..5bfaf25f4739 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -1,5 +1,4 @@ """Netatmo Media Source Implementation.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import datetime as dt import logging @@ -15,9 +14,11 @@ from homeassistant.components.media_source import ( PlayMedia, Unresolvable, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback -from .const import DATA_CAMERAS, DATA_EVENTS, DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER +from .data_handler import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry _LOGGER = logging.getLogger(__name__) MIME_TYPE = "application/x-mpegURL" @@ -29,7 +30,7 @@ class IncompatibleMediaSource(MediaSourceError): async def async_get_media_source(hass: HomeAssistant) -> NetatmoSource: """Set up Netatmo media source.""" - return NetatmoSource(hass) + return NetatmoSource(hass, async_get_loaded_entry(hass)) class NetatmoSource(MediaSource): @@ -37,11 +38,24 @@ class NetatmoSource(MediaSource): name: str = MANUFACTURER - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: NetatmoConfigEntry | None) -> None: """Initialize Netatmo source.""" super().__init__(DOMAIN) self.hass = hass - self.events = self.hass.data[DOMAIN][DATA_EVENTS] + self.entry = entry + + @property + def _data_handler(self) -> NetatmoDataHandler | None: + """Return the data handler of the config entry, if it is loaded.""" + if self.entry is None or self.entry.state is not ConfigEntryState.LOADED: + return None + return self.entry.runtime_data + + @property + def events(self) -> dict[str, dict]: + """Return the camera events.""" + data_handler = self._data_handler + return data_handler.events if data_handler else {} @override async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: @@ -85,7 +99,12 @@ class NetatmoSource(MediaSource): ) title = f"{created} - {message}" else: - title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER) + data_handler = self._data_handler + title = ( + data_handler.cameras.get(camera_id, MANUFACTURER) + if data_handler + else MANUFACTURER + ) thumbnail = None if event_id: diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 7596146b4749..d4d746b62fbd 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -1,5 +1,4 @@ """Support for the Netatmo climate schedule selector.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import override @@ -12,7 +11,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_URL_ENERGY, - DATA_SCHEDULES, DOMAIN, EVENT_TYPE_SCHEDULE, MANUFACTURER, @@ -100,18 +98,16 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): return if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - if schedule := self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].get(data["schedule_id"]): + if schedule := self.data_handler.schedules[self.home.entity_id].get( + data["schedule_id"] + ): self._attr_current_option = schedule.name self.async_write_ha_state() @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" - for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].items(): + for sid, schedule in self.data_handler.schedules[self.home.entity_id].items(): if schedule.name != option: continue _LOGGER.debug( @@ -130,9 +126,7 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): schedule = self.home.get_selected_schedule() assert schedule self._attr_current_option = schedule.name - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id] = ( - self.home.schedules - ) + self.data_handler.schedules[self.home.entity_id] = self.home.schedules self._attr_options = [ schedule.name for schedule in self.home.schedules.values() if schedule.name ] diff --git a/homeassistant/components/netatmo/services.py b/homeassistant/components/netatmo/services.py index 750e88baf308..b3ca63824a74 100644 --- a/homeassistant/components/netatmo/services.py +++ b/homeassistant/components/netatmo/services.py @@ -1,12 +1,11 @@ """Services for the Netatmo integration.""" -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .data_handler import NetatmoConfigEntry +from .data_handler import NetatmoConfigEntry, async_get_loaded_entry from .webhook import async_register_webhook, async_unregister_webhook SERVICE_REGISTER_WEBHOOK = "register_webhook" @@ -15,10 +14,7 @@ SERVICE_UNREGISTER_WEBHOOK = "unregister_webhook" def _get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry: """Return the loaded config entry or raise if unavailable.""" - entry: NetatmoConfigEntry | None = ( - hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) - ) - if entry is None or entry.state is not ConfigEntryState.LOADED: + if (entry := async_get_loaded_entry(hass)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="entry_not_loaded", diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index c34abe3c76bf..1670f2c2657c 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,9 +1,8 @@ """The Netatmo integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern - import logging import secrets +from typing import Any from aiohttp.web import Request import pyatmo @@ -31,8 +30,6 @@ from .const import ( ATTR_HOME_ID, ATTR_IS_KNOWN, CONF_CLOUDHOOK_URL, - DATA_DEVICE_IDS, - DATA_PERSONS, DEFAULT_PERSON, DOMAIN, EVENT_ID_MAP, @@ -40,7 +37,7 @@ from .const import ( WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import NetatmoConfigEntry +from .data_handler import NetatmoConfigEntry, NetatmoDataHandler _LOGGER = logging.getLogger(__name__) @@ -62,19 +59,31 @@ async def async_handle_webhook( _LOGGER.debug("Got webhook data: %s", data) + entry = next( + ( + entry + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.data.get(CONF_WEBHOOK_ID) == webhook_id + ), + None, + ) + if entry is None: + return + data_handler = entry.runtime_data + event_type = data.get(ATTR_EVENT_TYPE) if event_type in SUBEVENT_TYPE_MAP: - async_send_event(hass, event_type, data) + async_send_event(data_handler, event_type, data) for event_data in data.get(SUBEVENT_TYPE_MAP[event_type], []): - async_evaluate_event(hass, event_data) + async_evaluate_event(data_handler, event_data) else: - async_evaluate_event(hass, data) + async_evaluate_event(data_handler, data) -def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: +def async_evaluate_event(data_handler: NetatmoDataHandler, event_data: dict) -> None: """Evaluate events from webhook.""" event_type = event_data.get(ATTR_EVENT_TYPE, "None") @@ -82,20 +91,23 @@ def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: for person in event_data.get(ATTR_PERSONS, {}): person_event_data = dict(event_data) person_event_data[ATTR_ID] = person.get(ATTR_ID) - person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS][ + person_event_data[ATTR_NAME] = data_handler.persons[ event_data[ATTR_HOME_ID] ].get(person_event_data[ATTR_ID], DEFAULT_PERSON) person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN) person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL) - async_send_event(hass, event_type, person_event_data) + async_send_event(data_handler, event_type, person_event_data) else: - async_send_event(hass, event_type, event_data) + async_send_event(data_handler, event_type, event_data) -def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: +def async_send_event( + data_handler: NetatmoDataHandler, event_type: str, data: dict +) -> None: """Send events.""" + hass = data_handler.hass _LOGGER.debug("%s: %s", event_type, data) async_dispatcher_send( hass, @@ -103,16 +115,14 @@ def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: {"type": event_type, "data": data}, ) - event_data = { + event_data: dict[str, Any] = { "type": event_type, "data": data, } if event_type in EVENT_ID_MAP: data_device_id = data[EVENT_ID_MAP[event_type]] - event_data[ATTR_DEVICE_ID] = hass.data[DOMAIN][DATA_DEVICE_IDS].get( - data_device_id - ) + event_data[ATTR_DEVICE_ID] = data_handler.device_ids.get(data_device_id) hass.bus.async_fire( event_type=NETATMO_EVENT, diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 76abd3269cd0..601d9afc8636 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -1,6 +1,7 @@ """Test Local Media Source.""" import ast +from unittest.mock import AsyncMock import pytest @@ -12,24 +13,30 @@ from homeassistant.components.media_source import ( async_browse_media, async_resolve_media, ) -from homeassistant.components.netatmo import DATA_CAMERAS, DATA_EVENTS, DOMAIN +from homeassistant.components.netatmo.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import async_load_fixture +from .common import selected_platforms + +from tests.common import MockConfigEntry, async_load_fixture -async def test_async_browse_media(hass: HomeAssistant) -> None: +async def test_async_browse_media( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: """Test browse media.""" - assert await async_setup_component(hass, DOMAIN, {}) + with selected_platforms([Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - # Prepare cached Netatmo event date - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_EVENTS] = ast.literal_eval( + # Prepare cached Netatmo event data + data_handler = config_entry.runtime_data + data_handler.events = ast.literal_eval( await async_load_fixture(hass, "events.txt", DOMAIN) ) - - hass.data[DOMAIN][DATA_CAMERAS] = { + data_handler.cameras = { "12:34:56:78:90:ab": "MyCamera", "12:34:56:78:90:ac": "MyOutdoorCamera", } From b47413ba3471987879d3d9ca0de32dd2a0dd7f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 7 Jul 2026 16:11:32 +0200 Subject: [PATCH 184/707] Add Doorbell device type to discovery schemas in Matter event entities (#175802) Co-authored-by: Copilot --- homeassistant/components/matter/event.py | 5 +- tests/components/matter/common.py | 1 + .../matter/fixtures/nodes/mock_doorbell.json | 81 +++++++++++++++++++ .../matter/snapshots/test_event.ambr | 65 +++++++++++++++ .../matter/snapshots/test_sensor.ambr | 53 ++++++++++++ 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 tests/components/matter/fixtures/nodes/mock_doorbell.json diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index ad4d9aa4d6ce..66286b40be78 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -148,7 +148,10 @@ DISCOVERY_SCHEMAS = [ clusters.Switch.Attributes.CurrentPosition, clusters.Switch.Attributes.FeatureMap, ), - device_type=(device_types.GenericSwitch,), + device_type=( + device_types.Doorbell, + device_types.GenericSwitch, + ), optional_attributes=( clusters.Switch.Attributes.NumberOfPositions, clusters.FixedLabel.Attributes.LabelList, diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index d0b1385aa0ae..455ba54cf82c 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -57,6 +57,7 @@ FIXTURES = [ "mock_dimmable_plugin_unit", "mock_door_lock", "mock_door_lock_with_unbolt", + "mock_doorbell", "mock_extractor_hood", "mock_fan", "mock_flow_sensor", diff --git a/tests/components/matter/fixtures/nodes/mock_doorbell.json b/tests/components/matter/fixtures/nodes/mock_doorbell.json new file mode 100644 index 000000000000..539dc3720bd0 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mock_doorbell.json @@ -0,0 +1,81 @@ +{ + "node_id": 63, + "date_commissioned": "2026-07-06T11:13:20.917394", + "last_interview": "2026-07-06T11:13:20.917401", + "interview_version": 2, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Doorbell", + "0/40/4": 32768, + "0/40/5": "Mock Doorbell", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "prerelease", + "0/40/11": "20260707", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-doorbell", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 328, + "1": 1 + } + ], + "1/29/1": [3, 29, 59], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/59/65529": [], + "1/59/0": 2, + "1/59/65533": 1, + "1/59/1": 0, + "1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/59/65532": 14, + "1/59/65528": [] + }, + "available": true, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index 0ec4e27fff8a..fa19351158aa 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -1779,6 +1779,71 @@ 'state': 'unknown', }) # --- +# name: test_events[mock_doorbell][event.mock_doorbell_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'initial_press', + 'short_release', + 'long_press', + 'long_release', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.mock_doorbell_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Button', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-000000000000003F-MatterNodeDevice-1-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[mock_doorbell][event.mock_doorbell_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + 'initial_press', + 'short_release', + 'long_press', + 'long_release', + ]), + : 'Mock Doorbell Button', + }), + 'context': , + 'entity_id': 'event.mock_doorbell_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_events[mock_generic_switch][event.mock_generic_switch_button-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 536f054be32a..eca550430e28 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -18779,6 +18779,59 @@ 'state': '2025-01-01T13:59:35+00:00', }) # --- +# name: test_sensors[mock_doorbell][sensor.mock_doorbell_current_switch_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_doorbell_current_switch_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current switch position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-000000000000003F-MatterNodeDevice-1-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_doorbell][sensor.mock_doorbell_current_switch_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Doorbell Current switch position', + : , + }), + 'context': , + 'entity_id': 'sensor.mock_doorbell_current_switch_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[mock_extractor_hood][sensor.mock_extractor_hood_activated_carbon_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From e9968a4a35fed909b31a9f42849898da34284223 Mon Sep 17 00:00:00 2001 From: Crocmagnon Date: Tue, 7 Jul 2026 16:13:54 +0200 Subject: [PATCH 185/707] data_grand_lyon: add TCL parks and rides (#174289) --- .../components/data_grand_lyon/__init__.py | 9 +- .../components/data_grand_lyon/config_flow.py | 83 ++++++++++ .../components/data_grand_lyon/const.py | 2 + .../components/data_grand_lyon/coordinator.py | 68 ++++++++ .../components/data_grand_lyon/diagnostics.py | 4 + .../components/data_grand_lyon/entity.py | 21 ++- .../components/data_grand_lyon/icons.json | 9 ++ .../components/data_grand_lyon/sensor.py | 71 +++++++- .../components/data_grand_lyon/strings.json | 37 +++++ tests/components/data_grand_lyon/conftest.py | 61 +++++++ .../snapshots/test_diagnostics.ambr | 57 +++++++ .../snapshots/test_sensor.ambr | 153 ++++++++++++++++++ .../data_grand_lyon/test_config_flow.py | 125 ++++++++++++++ .../data_grand_lyon/test_diagnostics.py | 17 ++ .../components/data_grand_lyon/test_sensor.py | 118 ++++++++++++++ 15 files changed, 830 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/data_grand_lyon/__init__.py b/homeassistant/components/data_grand_lyon/__init__.py index 2f33bfe1d4f7..a7ccf285fe3d 100644 --- a/homeassistant/components/data_grand_lyon/__init__.py +++ b/homeassistant/components/data_grand_lyon/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .coordinator import ( DataGrandLyonConfigEntry, DataGrandLyonData, + DataGrandLyonParkAndRideCoordinator, DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator, ) @@ -32,13 +33,19 @@ async def async_setup_entry( tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client) velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client) + park_and_ride_coordinator = DataGrandLyonParkAndRideCoordinator(hass, entry, client) - coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator] + coordinators: list[DataUpdateCoordinator] = [ + tcl_coordinator, + velov_coordinator, + park_and_ride_coordinator, + ] await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) entry.runtime_data = DataGrandLyonData( tcl_coordinator=tcl_coordinator, velov_coordinator=velov_coordinator, + park_and_ride_coordinator=park_and_ride_coordinator, ) entry.async_on_unload(entry.add_update_listener(async_update_entry)) diff --git a/homeassistant/components/data_grand_lyon/config_flow.py b/homeassistant/components/data_grand_lyon/config_flow.py index 4cc15287542a..97b1df9f5e5b 100644 --- a/homeassistant/components/data_grand_lyon/config_flow.py +++ b/homeassistant/components/data_grand_lyon/config_flow.py @@ -7,8 +7,10 @@ from typing import Any, override from aiohttp import ClientError, ClientResponseError from data_grand_lyon_ha import ( DataGrandLyonClient, + TclParkAndRide, TclStop, VelovStation, + find_tcl_park_and_ride_by_id, find_tcl_stop_by_id, ) import voluptuous as vol @@ -32,9 +34,11 @@ from homeassistant.helpers.selector import ( from .const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -70,6 +74,7 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN): return { SUBENTRY_TYPE_STOP: StopSubentryFlowHandler, SUBENTRY_TYPE_VELOV_STATION: VelovStationSubentryFlowHandler, + SUBENTRY_TYPE_PARK_AND_RIDE: ParkAndRideSubentryFlowHandler, } @override @@ -396,3 +401,81 @@ def _velov_station_label(station: VelovStation) -> str: label += f" - {station.number}" return label + + +class ParkAndRideSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for adding a TCL park-and-ride (P+R).""" + + def __init__(self) -> None: + """Initialize the flow.""" + self._parks: list[TclParkAndRide] = [] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Pick a park-and-ride from the list fetched from the API, or enter one.""" + if not self._parks: + if error := await self._async_load_park_and_rides(): + return self.async_abort(reason=error) + + if user_input is not None: + park_id = user_input[CONF_PARK_ID] + entry = self._get_entry() + unique_id = f"park_and_ride_{park_id}" + + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + park = find_tcl_park_and_ride_by_id(self._parks, park_id) + return self.async_create_entry( + title=park.nom if park else park_id, + data={CONF_PARK_ID: park_id}, + unique_id=unique_id, + ) + + options = [ + SelectOptionDict(value=park.id, label=_park_and_ride_label(park)) + for park in sorted(self._parks, key=lambda p: (p.nom, p.id)) + ] + schema = vol.Schema( + { + vol.Required(CONF_PARK_ID): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + sort=False, + custom_value=True, + ) + ) + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + async def _async_load_park_and_rides(self) -> str | None: + """Fetch park-and-rides from the API, returning an error key on failure.""" + entry = self._get_entry() + session = async_get_clientsession(self.hass) + client = DataGrandLyonClient( + session=session, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + try: + self._parks = await client.get_tcl_park_and_rides() + except ClientResponseError as err: + if err.status in (401, 403): + return "invalid_auth" + return "cannot_connect" + except ClientError, TimeoutError: + return "cannot_connect" + except Exception: + _LOGGER.exception( + "Unexpected error fetching Data Grand Lyon park-and-rides" + ) + return "unknown" + return None + + +def _park_and_ride_label(park: TclParkAndRide) -> str: + return f"{park.nom} - {park.id}" diff --git a/homeassistant/components/data_grand_lyon/const.py b/homeassistant/components/data_grand_lyon/const.py index 49c66613a8e0..e77e2fcda134 100644 --- a/homeassistant/components/data_grand_lyon/const.py +++ b/homeassistant/components/data_grand_lyon/const.py @@ -7,7 +7,9 @@ LOGGER = logging.getLogger(__package__) SUBENTRY_TYPE_STOP = "stop" SUBENTRY_TYPE_VELOV_STATION = "velov_station" +SUBENTRY_TYPE_PARK_AND_RIDE = "park_and_ride" CONF_LINE = "line" CONF_STOP_ID = "stop_id" CONF_STATION_ID = "station_id" +CONF_PARK_ID = "park_id" diff --git a/homeassistant/components/data_grand_lyon/coordinator.py b/homeassistant/components/data_grand_lyon/coordinator.py index e3e25dc71af2..66562a9c23e9 100644 --- a/homeassistant/components/data_grand_lyon/coordinator.py +++ b/homeassistant/components/data_grand_lyon/coordinator.py @@ -7,9 +7,11 @@ from typing import override from aiohttp import ClientError, ClientResponseError from data_grand_lyon_ha import ( DataGrandLyonClient, + TclParkAndRide, TclPassage, VelovStation, filter_tcl_passages_by_lines_stops, + find_tcl_park_and_ride_by_id, find_velov_stations_by_ids, sort_tcl_passages_by_time, ) @@ -21,10 +23,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, LOGGER, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -36,6 +40,7 @@ class DataGrandLyonData: tcl_coordinator: DataGrandLyonTclCoordinator velov_coordinator: DataGrandLyonVelovCoordinator + park_and_ride_coordinator: DataGrandLyonParkAndRideCoordinator type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData] @@ -169,3 +174,66 @@ class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation subentry.subentry_id, ) return velov_stations + + +class DataGrandLyonParkAndRideCoordinator( + DataUpdateCoordinator[dict[str, TclParkAndRide]] +): + """Coordinator for TCL park-and-ride (P+R) facilities.""" + + config_entry: DataGrandLyonConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: DataGrandLyonConfigEntry, + client: DataGrandLyonClient, + ) -> None: + """Initialize the coordinator.""" + self.client = client + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_park_and_ride", + update_interval=timedelta(minutes=5), + ) + + @override + async def _async_update_data(self) -> dict[str, TclParkAndRide]: + """Fetch data for all monitored park-and-ride facilities.""" + park_subentries = list( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_PARK_AND_RIDE) + ) + if not park_subentries: + return {} + + try: + all_parks = await self.client.get_tcl_park_and_rides() + except ClientResponseError as err: + if err.status in (401, 403): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_park_and_ride", + ) from err + except (ClientError, TimeoutError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_park_and_ride", + ) from err + + parks: dict[str, TclParkAndRide] = {} + for subentry in park_subentries: + park = find_tcl_park_and_ride_by_id(all_parks, subentry.data[CONF_PARK_ID]) + if park is not None: + parks[subentry.subentry_id] = park + else: + LOGGER.warning( + "Park-and-ride not found for subentry %s", + subentry.subentry_id, + ) + return parks diff --git a/homeassistant/components/data_grand_lyon/diagnostics.py b/homeassistant/components/data_grand_lyon/diagnostics.py index 8e7788e4025d..3907b40a6b75 100644 --- a/homeassistant/components/data_grand_lyon/diagnostics.py +++ b/homeassistant/components/data_grand_lyon/diagnostics.py @@ -27,5 +27,9 @@ async def async_get_config_entry_diagnostics( subentry_id: asdict(station) for subentry_id, station in entry.runtime_data.velov_coordinator.data.items() }, + "park_and_rides": { + subentry_id: asdict(park) + for subentry_id, park in entry.runtime_data.park_and_ride_coordinator.data.items() + }, }, } diff --git a/homeassistant/components/data_grand_lyon/entity.py b/homeassistant/components/data_grand_lyon/entity.py index d65bb85fac7f..3f47311dc697 100644 --- a/homeassistant/components/data_grand_lyon/entity.py +++ b/homeassistant/components/data_grand_lyon/entity.py @@ -11,7 +11,11 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import DOMAIN -from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator +from .coordinator import ( + DataGrandLyonParkAndRideCoordinator, + DataGrandLyonTclCoordinator, + DataGrandLyonVelovCoordinator, +) class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator]( @@ -75,3 +79,18 @@ class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator ) -> None: """Initialize the Vélo'v entity.""" super().__init__(coordinator, subentry, description, "JCDecaux", "Station") + + +class DataGrandLyonParkAndRideEntity( + DataGrandLyonEntity[DataGrandLyonParkAndRideCoordinator] +): + """Base entity for Data Grand Lyon park-and-ride facilities.""" + + def __init__( + self, + coordinator: DataGrandLyonParkAndRideCoordinator, + subentry: ConfigSubentry, + description: EntityDescription, + ) -> None: + """Initialize the park-and-ride entity.""" + super().__init__(coordinator, subentry, description, "TCL", "Park & Ride") diff --git a/homeassistant/components/data_grand_lyon/icons.json b/homeassistant/components/data_grand_lyon/icons.json index 893b337880de..b8351d150a72 100644 --- a/homeassistant/components/data_grand_lyon/icons.json +++ b/homeassistant/components/data_grand_lyon/icons.json @@ -9,6 +9,9 @@ } }, "sensor": { + "accessible_spaces": { + "default": "mdi:wheelchair-accessibility" + }, "available_bikes": { "default": "mdi:bike" }, @@ -18,6 +21,9 @@ "available_mechanical_bikes": { "default": "mdi:bike" }, + "available_spaces": { + "default": "mdi:parking" + }, "available_stands": { "default": "mdi:parking" }, @@ -65,6 +71,9 @@ "state": { "estimated": "mdi:clock-check-outline" } + }, + "park_and_ride_capacity": { + "default": "mdi:counter" } } } diff --git a/homeassistant/components/data_grand_lyon/sensor.py b/homeassistant/components/data_grand_lyon/sensor.py index 0da6e49a7264..4e72d87c0733 100644 --- a/homeassistant/components/data_grand_lyon/sensor.py +++ b/homeassistant/components/data_grand_lyon/sensor.py @@ -6,20 +6,29 @@ from datetime import datetime from typing import override from zoneinfo import ZoneInfo -from data_grand_lyon_ha import TclPassage, TclPassageType, VelovStation +from data_grand_lyon_ha import TclParkAndRide, TclPassage, TclPassageType, VelovStation from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION +from .const import ( + SUBENTRY_TYPE_PARK_AND_RIDE, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV_STATION, +) from .coordinator import DataGrandLyonConfigEntry -from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity +from .entity import ( + DataGrandLyonParkAndRideEntity, + DataGrandLyonTclEntity, + DataGrandLyonVelovEntity, +) PARALLEL_UPDATES = 0 @@ -164,6 +173,36 @@ VELOV_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonVelovSensorEntityDescription, ...] ) +@dataclass(frozen=True, kw_only=True) +class DataGrandLyonParkAndRideSensorEntityDescription(SensorEntityDescription): + """Describes a Data Grand Lyon park-and-ride sensor entity.""" + + value_fn: Callable[[TclParkAndRide], StateType] + + +PARK_AND_RIDE_SENSOR_DESCRIPTIONS: tuple[ + DataGrandLyonParkAndRideSensorEntityDescription, ... +] = ( + DataGrandLyonParkAndRideSensorEntityDescription( + key="available_spaces", + translation_key="available_spaces", + value_fn=lambda p: p.nb_tot_place_dispo, + ), + DataGrandLyonParkAndRideSensorEntityDescription( + key="capacity", + translation_key="park_and_ride_capacity", + value_fn=lambda p: p.capacite, + ), + DataGrandLyonParkAndRideSensorEntityDescription( + key="accessible_spaces", + translation_key="accessible_spaces", + value_fn=lambda p: p.place_handi, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: DataGrandLyonConfigEntry, @@ -172,6 +211,7 @@ async def async_setup_entry( """Set up Data Grand Lyon sensor entities.""" tcl_coordinator = entry.runtime_data.tcl_coordinator velov_coordinator = entry.runtime_data.velov_coordinator + park_and_ride_coordinator = entry.runtime_data.park_and_ride_coordinator for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP): async_add_entities( @@ -191,6 +231,17 @@ async def async_setup_entry( config_subentry_id=subentry.subentry_id, ) + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PARK_AND_RIDE): + async_add_entities( + ( + DataGrandLyonParkAndRideSensor( + park_and_ride_coordinator, subentry, description + ) + for description in PARK_AND_RIDE_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry.subentry_id, + ) + class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity): """Sensor for Data Grand Lyon stop departures.""" @@ -227,3 +278,17 @@ class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity): return self.entity_description.value_fn( self.coordinator.data[self._subentry_id] ) + + +class DataGrandLyonParkAndRideSensor(DataGrandLyonParkAndRideEntity, SensorEntity): + """Sensor for Data Grand Lyon park-and-ride facility.""" + + entity_description: DataGrandLyonParkAndRideSensorEntityDescription + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn( + self.coordinator.data[self._subentry_id] + ) diff --git a/homeassistant/components/data_grand_lyon/strings.json b/homeassistant/components/data_grand_lyon/strings.json index 43be121570da..eb08a461a352 100644 --- a/homeassistant/components/data_grand_lyon/strings.json +++ b/homeassistant/components/data_grand_lyon/strings.json @@ -44,6 +44,28 @@ } }, "config_subentries": { + "park_and_ride": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Park & ride", + "initiate_flow": { + "user": "Add park & ride" + }, + "step": { + "user": { + "data": { + "park_id": "Park & ride" + }, + "data_description": { + "park_id": "Search by name, or enter a park & ride ID directly." + } + } + } + }, "stop": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", @@ -107,6 +129,10 @@ } }, "sensor": { + "accessible_spaces": { + "name": "Accessible parking spaces", + "unit_of_measurement": "spaces" + }, "available_bikes": { "name": "Available bikes", "unit_of_measurement": "bikes" @@ -119,6 +145,10 @@ "name": "Available mechanical bikes", "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_bikes::unit_of_measurement%]" }, + "available_spaces": { + "name": "Available parking spaces", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::accessible_spaces::unit_of_measurement%]" + }, "available_stands": { "name": "Available stands", "unit_of_measurement": "stands" @@ -173,6 +203,10 @@ "estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]", "theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]" } + }, + "park_and_ride_capacity": { + "name": "Capacity", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::accessible_spaces::unit_of_measurement%]" } } }, @@ -180,6 +214,9 @@ "auth_failed": { "message": "Authentication failed for Data Grand Lyon." }, + "update_failed_park_and_ride": { + "message": "Error fetching park & ride availability from Data Grand Lyon." + }, "update_failed_tcl": { "message": "Error fetching TCL departures from Data Grand Lyon." }, diff --git a/tests/components/data_grand_lyon/conftest.py b/tests/components/data_grand_lyon/conftest.py index 5cbf124aa2d5..672ec2baba59 100644 --- a/tests/components/data_grand_lyon/conftest.py +++ b/tests/components/data_grand_lyon/conftest.py @@ -5,6 +5,7 @@ from datetime import datetime from unittest.mock import AsyncMock, patch from data_grand_lyon_ha import ( + TclParkAndRide, TclPassage, TclPassageType, TclStop, @@ -17,9 +18,11 @@ import pytest from homeassistant.components.data_grand_lyon.const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -138,6 +141,36 @@ MOCK_VELOV_STATIONS = [ ] +MOCK_PARK_AND_RIDE = TclParkAndRide( + id="P+R Gorge de Loup", + gid=10, + nom="Gorge de Loup", + capacite=240, + place_handi=6, + horaires="24h/24", + p_surv=True, + nb_tot_place_dispo=42, + last_update=datetime(2026, 4, 10, 14, 0), + last_update_fme=datetime(2026, 4, 10, 13, 55), +) + +MOCK_PARK_AND_RIDES = [ + MOCK_PARK_AND_RIDE, + TclParkAndRide( + id="P+R Oullins La Saulaie", + gid=20, + nom="Oullins La Saulaie", + capacite=420, + place_handi=10, + horaires="5h-1h", + p_surv=False, + nb_tot_place_dispo=120, + last_update=datetime(2026, 4, 10, 14, 0), + last_update_fme=datetime(2026, 4, 10, 13, 55), + ), +] + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -175,6 +208,20 @@ def mock_velov_subentries() -> list[ConfigSubentryData]: ] +@pytest.fixture +def mock_park_and_ride_subentries() -> list[ConfigSubentryData]: + """Mock park-and-ride subentries.""" + return [ + ConfigSubentryData( + data={CONF_PARK_ID: "P+R Gorge de Loup"}, + subentry_id="park_1", + subentry_type=SUBENTRY_TYPE_PARK_AND_RIDE, + title="Gorge de Loup", + unique_id="park_and_ride_P+R Gorge de Loup", + ) + ] + + @pytest.fixture def mock_config_entry( mock_subentries: list[ConfigSubentryData], @@ -201,6 +248,19 @@ def mock_velov_config_entry( ) +@pytest.fixture +def mock_park_and_ride_config_entry( + mock_park_and_ride_subentries: list[ConfigSubentryData], +) -> MockConfigEntry: + """Create a mock config entry with park-and-ride subentries.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=mock_park_and_ride_subentries, + ) + + @pytest.fixture def mock_tcl_client() -> Generator[AsyncMock]: """Mock DataGrandLyonClient for coordinator and config flow.""" @@ -218,4 +278,5 @@ def mock_tcl_client() -> Generator[AsyncMock]: client.get_tcl_passages.return_value = MOCK_DEPARTURES client.get_tcl_stops.return_value = MOCK_TCL_STOPS client.get_velov_stations.return_value = MOCK_VELOV_STATIONS + client.get_tcl_park_and_rides.return_value = MOCK_PARK_AND_RIDES yield client diff --git a/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr b/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr index 05986de52c95..83f752ab32be 100644 --- a/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr +++ b/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr @@ -32,6 +32,8 @@ 'version': 1, }), 'coordinator_data': dict({ + 'park_and_rides': dict({ + }), 'stops': dict({ 'stop_1': list([ dict({ @@ -61,6 +63,59 @@ }), }) # --- +# name: test_config_entry_diagnostics_with_park_and_ride + dict({ + 'config_entry': dict({ + 'data': dict({ + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'data_grand_lyon', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + dict({ + 'data': dict({ + 'park_id': 'P+R Gorge de Loup', + }), + 'subentry_type': 'park_and_ride', + 'title': 'Gorge de Loup', + 'unique_id': 'park_and_ride_P+R Gorge de Loup', + }), + ]), + 'title': 'Data Grand Lyon', + 'unique_id': None, + 'version': 1, + }), + 'coordinator_data': dict({ + 'park_and_rides': dict({ + 'park_1': dict({ + 'capacite': 240, + 'gid': 10, + 'horaires': '24h/24', + 'id': 'P+R Gorge de Loup', + 'last_update': '2026-04-10T14:00:00', + 'last_update_fme': '2026-04-10T13:55:00', + 'nb_tot_place_dispo': 42, + 'nom': 'Gorge de Loup', + 'p_surv': True, + 'place_handi': 6, + }), + }), + 'stops': dict({ + }), + 'velov_stations': dict({ + }), + }), + }) +# --- # name: test_config_entry_diagnostics_with_velov dict({ 'config_entry': dict({ @@ -93,6 +148,8 @@ 'version': 1, }), 'coordinator_data': dict({ + 'park_and_rides': dict({ + }), 'stops': dict({ }), 'velov_stations': dict({ diff --git a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr index 4820fb4e75d3..9f44f5f8da5e 100644 --- a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr +++ b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr @@ -482,6 +482,159 @@ 'state': 'unavailable', }) # --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_accessible_parking_spaces-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gorge_de_loup_accessible_parking_spaces', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Accessible parking spaces', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Accessible parking spaces', + 'platform': 'data_grand_lyon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'accessible_spaces', + 'unique_id': 'park_and_ride_P+R Gorge de Loup-accessible_spaces', + 'unit_of_measurement': 'spaces', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_accessible_parking_spaces-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Gorge de Loup Accessible parking spaces', + : 'spaces', + }), + 'context': , + 'entity_id': 'sensor.gorge_de_loup_accessible_parking_spaces', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_available_parking_spaces-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gorge_de_loup_available_parking_spaces', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Available parking spaces', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Available parking spaces', + 'platform': 'data_grand_lyon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'available_spaces', + 'unique_id': 'park_and_ride_P+R Gorge de Loup-available_spaces', + 'unit_of_measurement': 'spaces', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_available_parking_spaces-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Gorge de Loup Available parking spaces', + : 'spaces', + }), + 'context': , + 'entity_id': 'sensor.gorge_de_loup_available_parking_spaces', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gorge_de_loup_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Capacity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Capacity', + 'platform': 'data_grand_lyon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'park_and_ride_capacity', + 'unique_id': 'park_and_ride_P+R Gorge de Loup-capacity', + 'unit_of_measurement': 'spaces', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Gorge de Loup Capacity', + : 'spaces', + }), + 'context': , + 'entity_id': 'sensor.gorge_de_loup_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '240', + }) +# --- # name: test_velov_all_entities[sensor.velo_v_1001_available_bikes-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/data_grand_lyon/test_config_flow.py b/tests/components/data_grand_lyon/test_config_flow.py index a18dfcf03c2e..c64879ef0182 100644 --- a/tests/components/data_grand_lyon/test_config_flow.py +++ b/tests/components/data_grand_lyon/test_config_flow.py @@ -8,9 +8,11 @@ import pytest from homeassistant import config_entries from homeassistant.components.data_grand_lyon.const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -596,3 +598,126 @@ async def test_velov_station_subentry_picker_load_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason + + +# Park-and-ride subentry tests + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_park_and_ride_subentry_picker_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test adding a park-and-ride subentry by picking one from the list.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert mock_tcl_client.get_tcl_park_and_rides.await_count == 1 + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PARK_ID: "P+R Gorge de Loup"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gorge de Loup" + assert result["data"] == {CONF_PARK_ID: "P+R Gorge de Loup"} + assert result["unique_id"] == "park_and_ride_P+R Gorge de Loup" + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_park_and_ride_subentry_custom_value_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test adding a park-and-ride by typing an ID not present in the list.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PARK_ID: "P+R Unknown"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "P+R Unknown" + assert result["data"] == {CONF_PARK_ID: "P+R Unknown"} + assert result["unique_id"] == "park_and_ride_P+R Unknown" + + +async def test_park_and_ride_subentry_already_configured( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test park-and-ride subentry aborts if same facility already exists.""" + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_park_and_ride_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PARK_ID: "P+R Gorge de Loup"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + ( + ClientResponseError(request_info=None, history=(), status=500), + "cannot_connect", + ), + ( + ClientResponseError(request_info=None, history=(), status=401), + "invalid_auth", + ), + (ClientConnectionError("boom"), "cannot_connect"), + (TimeoutError("boom"), "cannot_connect"), + (RuntimeError("boom"), "unknown"), + ], +) +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_park_and_ride_subentry_picker_load_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, + side_effect: Exception, + reason: str, +) -> None: + """Test picker aborts with the right reason when loading park-and-rides fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_tcl_client.get_tcl_park_and_rides.side_effect = side_effect + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason diff --git a/tests/components/data_grand_lyon/test_diagnostics.py b/tests/components/data_grand_lyon/test_diagnostics.py index 28ceda0f7326..424036bac889 100644 --- a/tests/components/data_grand_lyon/test_diagnostics.py +++ b/tests/components/data_grand_lyon/test_diagnostics.py @@ -44,3 +44,20 @@ async def test_config_entry_diagnostics_with_velov( assert await get_diagnostics_for_config_entry( hass, hass_client, mock_velov_config_entry ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id")) + + +async def test_config_entry_diagnostics_with_park_and_ride( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics with park-and-ride data.""" + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_park_and_ride_config_entry + ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id")) diff --git a/tests/components/data_grand_lyon/test_sensor.py b/tests/components/data_grand_lyon/test_sensor.py index cfb6706af47b..d00f4b47c235 100644 --- a/tests/components/data_grand_lyon/test_sensor.py +++ b/tests/components/data_grand_lyon/test_sensor.py @@ -344,3 +344,121 @@ async def test_coordinator_mixed_partial_failure( await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + + +# Park-and-ride sensor tests + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_park_and_ride_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test all park-and-ride sensor entities (state, attributes, registry).""" + with patch("homeassistant.components.data_grand_lyon.PLATFORMS", [Platform.SENSOR]): + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_park_and_ride_config_entry.entry_id + ) + + +async def test_park_and_ride_sensor_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that diagnostic park-and-ride sensors are disabled by default.""" + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + for unique_id in ("park_and_ride_P+R Gorge de Loup-accessible_spaces",): + entry = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entry is not None, unique_id + reg_entry = entity_registry.async_get(entry) + assert reg_entry is not None, unique_id + assert reg_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + for unique_id in ( + "park_and_ride_P+R Gorge de Loup-available_spaces", + "park_and_ride_P+R Gorge de Loup-capacity", + ): + entry = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entry is not None, unique_id + reg_entry = entity_registry.async_get(entry) + assert reg_entry is not None, unique_id + assert reg_entry.disabled_by is None + + +async def test_park_and_ride_sensor_no_data( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that park-and-ride sensors are unavailable when facility not found.""" + mock_tcl_client.get_tcl_park_and_rides.return_value = [] + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gorge_de_loup_available_parking_spaces") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_coordinator_park_and_ride_auth_error( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator triggers reauth on park-and-ride auth failure.""" + mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientResponseError( + Mock(), (), status=401 + ) + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert any(flow["context"].get("source") == SOURCE_REAUTH for flow in flows) + + +async def test_coordinator_park_and_ride_http_error( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator raises UpdateFailed on non-auth HTTP errors for P+R.""" + mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientResponseError( + Mock(), (), status=500 + ) + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_park_and_ride_fetch_error( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator raises UpdateFailed on park-and-ride fetch error.""" + mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientConnectionError( + "API down" + ) + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_RETRY From 7fcffae19d6ace452b0a7259a4f7e29dc5bdbb06 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 7 Jul 2026 09:22:22 -0500 Subject: [PATCH 186/707] Don't restart a Wyoming satellite pipeline if there's an error (#175798) --- .../components/wyoming/assist_satellite.py | 20 +++- tests/components/wyoming/test_satellite.py | 91 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 2176f89e2fe5..a08772e057e1 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -110,6 +110,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._client: AsyncTcpClient | None = None self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) self._is_pipeline_running = False + self._pipeline_error = False self._pipeline_ended_event = asyncio.Event() self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._pipeline_id: str | None = None @@ -316,7 +317,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.ERROR: - # Pipeline error + # Pipeline error. Prevents an "always on" satellite from restarting + # a failing pipeline in a tight loop (e.g. on a config error). + self._pipeline_error = True if event.data: self.config_entry.async_create_background_task( self.hass, @@ -623,12 +626,24 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): # Clear last wake word detection wake_word_phrase = None - if (run_pipeline is not None) and run_pipeline.restart_on_end: + if ( + (run_pipeline is not None) + and run_pipeline.restart_on_end + and not self._pipeline_error + ): # Automatically restart pipeline. # Used with "always on" streaming satellites. self._run_pipeline_once(run_pipeline) continue + if self._pipeline_error: + # Don't restart a failing pipeline in a tight loop; wait + # for the satellite to request a new run. + _LOGGER.debug( + "Not restarting pipeline after error; " + "waiting for next satellite request" + ) + if client_event_task not in done: continue @@ -736,6 +751,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._audio_queue = asyncio.Queue() self._is_pipeline_running = True + self._pipeline_error = False self._pipeline_ended_event.clear() self.config_entry.async_create_background_task( self.hass, diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index d621471916b9..3bb84a3ce383 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -455,6 +455,97 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await hass.async_block_till_done() +async def test_satellite_pipeline_error_no_restart(hass: HomeAssistant) -> None: + """Test that a pipeline error does not auto-restart an "always on" satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS, + restart_on_end=True, + ).event(), + ] + + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + run_pipeline_called.set() + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + ): + entry = await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Reset so we can detect an (unwanted) restart below + run_pipeline_called.clear() + + # Pipeline fails, then ends + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.ERROR, + {"code": "test-code", "message": "test-message"}, + ) + ) + pipeline_event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + + # A ping/pong round trip guarantees the loop has processed the run end + mock_client.inject_event(Ping("test-ping").event()) + async with asyncio.timeout(1): + await mock_client.pong_event.wait() + + # Pipeline must not have automatically restarted after the error + assert not run_pipeline_called.is_set() + + # A new run request from the satellite clears the error and runs again + mock_client.inject_event( + RunPipeline( + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS, + restart_on_end=True, + ).event() + ) + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_satellite_muted(hass: HomeAssistant) -> None: """Test callback for a satellite that has been muted.""" on_muted_event = asyncio.Event() From 97c9c680d01b2b0a53007fb2fbd62f99a48ae7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 7 Jul 2026 15:30:18 +0100 Subject: [PATCH 187/707] Make copilot PR template check instruction stricter (#175864) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- script/gen_copilot_instructions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 523ac8485fcc..3be73a02a55b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,7 +8,7 @@ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. -- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present and filled in, except the `## Breaking change` section which is optional. +- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. # GitHub Copilot & Claude Code Instructions diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index 4c8894b95ad5..d1e91cb3f993 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -25,7 +25,7 @@ COPILOT_SPECIFIC_INSTRUCTIONS = """ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. -- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present and filled in, except the `## Breaking change` section which is optional. +- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. """ INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """--- From 323a4d4af929c05ca62a905c1f7193fcae1e7f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Sva=C5=A1ek?= Date: Tue, 7 Jul 2026 16:41:27 +0200 Subject: [PATCH 188/707] Add NeoPool integration (#173779) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/neopool/__init__.py | 28 + .../components/neopool/config_flow.py | 75 + homeassistant/components/neopool/const.py | 14 + .../components/neopool/coordinator.py | 100 ++ homeassistant/components/neopool/entity.py | 36 + homeassistant/components/neopool/icons.json | 65 + .../components/neopool/manifest.json | 12 + .../components/neopool/quality_scale.yaml | 96 ++ homeassistant/components/neopool/sensor.py | 380 +++++ homeassistant/components/neopool/strings.json | 150 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/neopool/__init__.py | 14 + tests/components/neopool/conftest.py | 175 +++ .../neopool/snapshots/test_sensor.ambr | 1379 +++++++++++++++++ tests/components/neopool/test_config_flow.py | 106 ++ tests/components/neopool/test_init.py | 192 +++ tests/components/neopool/test_sensor.py | 242 +++ 22 files changed, 3087 insertions(+) create mode 100644 homeassistant/components/neopool/__init__.py create mode 100644 homeassistant/components/neopool/config_flow.py create mode 100644 homeassistant/components/neopool/const.py create mode 100644 homeassistant/components/neopool/coordinator.py create mode 100644 homeassistant/components/neopool/entity.py create mode 100644 homeassistant/components/neopool/icons.json create mode 100644 homeassistant/components/neopool/manifest.json create mode 100644 homeassistant/components/neopool/quality_scale.yaml create mode 100644 homeassistant/components/neopool/sensor.py create mode 100644 homeassistant/components/neopool/strings.json create mode 100644 tests/components/neopool/__init__.py create mode 100644 tests/components/neopool/conftest.py create mode 100644 tests/components/neopool/snapshots/test_sensor.ambr create mode 100644 tests/components/neopool/test_config_flow.py create mode 100644 tests/components/neopool/test_init.py create mode 100644 tests/components/neopool/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 4b56c5b2c206..5b5f29e7ed2f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -395,6 +395,7 @@ homeassistant.components.nam.* homeassistant.components.namecheapdns.* homeassistant.components.nasweb.* homeassistant.components.neato.* +homeassistant.components.neopool.* homeassistant.components.nest.* homeassistant.components.netatmo.* homeassistant.components.network.* diff --git a/CODEOWNERS b/CODEOWNERS index 3b00a23d0592..402b04d339c7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1193,6 +1193,8 @@ CLAUDE.md @home-assistant/core /tests/components/nasweb/ @nasWebio /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul +/homeassistant/components/neopool/ @svasek +/tests/components/neopool/ @svasek /homeassistant/components/ness_alarm/ @nickw444 @poshy163 /tests/components/ness_alarm/ @nickw444 @poshy163 /homeassistant/components/nest/ @allenporter diff --git a/homeassistant/components/neopool/__init__.py b/homeassistant/components/neopool/__init__.py new file mode 100644 index 000000000000..166f04f02d20 --- /dev/null +++ b/homeassistant/components/neopool/__init__.py @@ -0,0 +1,28 @@ +"""NeoPool integration for Home Assistant.""" + +from neopool_modbus import NeoPoolModbusClient + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import NeoPoolConfigEntry, NeoPoolCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> bool: + """Set up the NeoPool integration from a config entry.""" + client = NeoPoolModbusClient(entry.data) + coordinator = NeoPoolCoordinator(hass, client, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> bool: + """Unload a NeoPool config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.client.close() + return unload_ok diff --git a/homeassistant/components/neopool/config_flow.py b/homeassistant/components/neopool/config_flow.py new file mode 100644 index 000000000000..13a4c547f150 --- /dev/null +++ b/homeassistant/components/neopool/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for the NeoPool integration.""" + +from typing import Any, override + +from neopool_modbus import async_probe_serial +from neopool_modbus.exceptions import ( + NeoPoolConnectionError, + NeoPoolModbusError, + NeoPoolTimeoutError, +) +from neopool_modbus.registers import DEFAULT_MODBUS_FRAMER +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import CURRENT_VERSION, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN + + +async def _async_probe(user_input: dict[str, Any]) -> tuple[str | None, str | None]: + """Probe a device using user-supplied connection parameters.""" + try: + serial = await async_probe_serial( + user_input[CONF_HOST], + port=user_input[CONF_PORT], + unit_id=user_input["unit_id"], + framer=user_input["modbus_framer"], + ) + except NeoPoolConnectionError, NeoPoolTimeoutError: + return None, "cannot_connect" + except NeoPoolModbusError: + return None, "cannot_read_modbus" + return serial, None + + +class NeoPoolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for NeoPool.""" + + VERSION = CURRENT_VERSION + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step of the configuration flow.""" + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Optional("unit_id", default=DEFAULT_UNIT_ID): vol.Coerce(int), + vol.Optional( + "modbus_framer", + default=DEFAULT_MODBUS_FRAMER, + ): vol.In(("tcp", "rtu")), + } + ) + errors: dict[str, str] = {} + if user_input is not None: + serial, error_key = await _async_probe(user_input) + if error_key: + errors[CONF_HOST] = error_key + else: + assert serial is not None + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/neopool/const.py b/homeassistant/components/neopool/const.py new file mode 100644 index 000000000000..0307581f2da2 --- /dev/null +++ b/homeassistant/components/neopool/const.py @@ -0,0 +1,14 @@ +"""Constants for the NeoPool integration.""" + +from homeassistant.const import Platform + +DOMAIN = "neopool" +NAME = "NeoPool" + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +DEFAULT_SCAN_INTERVAL = 20 # in seconds +DEFAULT_PORT = 502 +DEFAULT_UNIT_ID = 1 + +CURRENT_VERSION = 6 diff --git a/homeassistant/components/neopool/coordinator.py b/homeassistant/components/neopool/coordinator.py new file mode 100644 index 000000000000..01b3d62dcf6c --- /dev/null +++ b/homeassistant/components/neopool/coordinator.py @@ -0,0 +1,100 @@ +"""Data update coordinator for the NeoPool integration.""" + +from datetime import timedelta +import logging +from typing import Any, override + +from neopool_modbus import NeoPoolModbusClient +from neopool_modbus.exceptions import NeoPoolError +from neopool_modbus.registers import MAX_RELAY_GPIO, find_corrupted_gpio_registers + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type NeoPoolConfigEntry = ConfigEntry["NeoPoolCoordinator"] + + +class NeoPoolCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for NeoPool platform.""" + + client: NeoPoolModbusClient + config_entry: NeoPoolConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: NeoPoolModbusClient, + entry: NeoPoolConfigEntry, + ) -> None: + """Initialise the NeoPool data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} coordinator", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, + ) + self.client = client + self._corrupted_gpio_state: frozenset[tuple[str, int]] | None = None + + def _check_gpio_registers(self, data: dict[str, Any]) -> None: + """Validate GPIO register values and (re-)raise or clear the repair issue.""" + corrupted = find_corrupted_gpio_registers(data) + corrupted_state = frozenset((key, value) for key, _, value in corrupted) + + if corrupted_state == self._corrupted_gpio_state: + return + + for key, label, value in corrupted: + _LOGGER.error( + "Corrupted GPIO register %s (%s): value %d (0x%04X) is outside " + "valid range 0-%d. The pool controller may malfunction", + key, + label, + value, + value & 0xFFFF, + MAX_RELAY_GPIO, + ) + + self._corrupted_gpio_state = corrupted_state + + if corrupted: + details = "\n".join( + f"- **{label}** (`{key}`): value **{value}** (expected 0-{MAX_RELAY_GPIO})" + for key, label, value in corrupted + ) + ir.async_create_issue( + self.hass, + DOMAIN, + "corrupted_gpio", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="corrupted_gpio", + translation_placeholders={"details": details}, + ) + else: + # Clear a previously raised repair issue once the device is healthy. + ir.async_delete_issue(self.hass, DOMAIN, "corrupted_gpio") + + @override + async def _async_update_data(self) -> dict[str, Any]: + """Fetch the latest data from the pool controller.""" + try: + data = await self.client.async_read_all() + except (NeoPoolError, OSError, TimeoutError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="modbus_communication_error", + translation_placeholders={"error": str(err)}, + ) from err + + self._check_gpio_registers(data) + + return data diff --git a/homeassistant/components/neopool/entity.py b/homeassistant/components/neopool/entity.py new file mode 100644 index 000000000000..3c3fdd3e7a1c --- /dev/null +++ b/homeassistant/components/neopool/entity.py @@ -0,0 +1,36 @@ +"""Base entity class for the NeoPool integration.""" + +from typing import override + +from neopool_modbus.decoders import get_machine_name, parse_version + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, NAME +from .coordinator import NeoPoolCoordinator + + +class NeoPoolEntity(CoordinatorEntity[NeoPoolCoordinator]): + """Base class for NeoPool entities.""" + + _attr_has_entity_name = True + + @property + @override + def device_info(self) -> DeviceInfo: + """Return device information for the entity.""" + data = self.coordinator.data or {} + unique_id = self.coordinator.config_entry.unique_id + assert unique_id is not None + machine_type = (get_machine_name(data) or "").strip() + model_prefix = "NeoPool Compatible: " if machine_type else "NeoPool Compatible" + + return DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=NAME, + model=f"{model_prefix}{machine_type}".strip(), + manufacturer="Hayward (Sugar Valley)", + sw_version=f"v{parse_version(data.get('MBF_POWER_MODULE_VERSION'))} (v{parse_version(data.get('MBF_PAR_VERSION'))})", + serial_number=unique_id, + ) diff --git a/homeassistant/components/neopool/icons.json b/homeassistant/components/neopool/icons.json new file mode 100644 index 000000000000..90d561743cf1 --- /dev/null +++ b/homeassistant/components/neopool/icons.json @@ -0,0 +1,65 @@ +{ + "entity": { + "sensor": { + "filt_mode": { + "default": "mdi:water-sync", + "state": { + "auto": "mdi:water-boiler-auto", + "backwash": "mdi:water-boiler-off", + "heating": "mdi:water-boiler-alert", + "intelligent": "mdi:water-boiler-auto", + "manual": "mdi:water-boiler-alert", + "smart": "mdi:water-boiler-auto" + } + }, + "filtration_speed": { + "default": "mdi:fan" + }, + "filtvalve_remaining": { + "default": "mdi:timer-sand" + }, + "hidro_current": { + "default": "mdi:air-humidifier-off", + "range": { + "10": "mdi:air-humidifier" + } + }, + "hidro_polarity": { + "default": "mdi:plus-minus-variant" + }, + "intelligent_intervals": { + "default": "mdi:counter" + }, + "intelligent_tt_next_interval": { + "default": "mdi:timeline-clock-outline" + }, + "ion_current": { + "default": "mdi:atom" + }, + "ion_polarity": { + "default": "mdi:plus-minus-variant" + }, + "measure_cl": { + "default": "mdi:shaker-outline" + }, + "measure_rx": { + "default": "mdi:gradient-vertical" + }, + "ph_pump_status": { + "default": "mdi:pump" + }, + "ph_status_alarm": { + "default": "mdi:ph", + "state": { + "ok": "mdi:check-circle-outline", + "ph_high": "mdi:alert", + "ph_low": "mdi:alert", + "ph_over": "mdi:alert", + "ph_under": "mdi:alert", + "pump_stopped": "mdi:alert", + "tank_level": "mdi:alert" + } + } + } + } +} diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json new file mode 100644 index 000000000000..9c0658e5031b --- /dev/null +++ b/homeassistant/components/neopool/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "neopool", + "name": "NeoPool", + "codeowners": ["@svasek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/neopool", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["neopool_modbus"], + "quality_scale": "silver", + "requirements": ["neopool-modbus==3.6.0"] +} diff --git a/homeassistant/components/neopool/quality_scale.yaml b/homeassistant/components/neopool/quality_scale.yaml new file mode 100644 index 000000000000..1893a01bff50 --- /dev/null +++ b/homeassistant/components/neopool/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register any service actions. + docs-conditions: + status: exempt + comment: The integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: The integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: | + Entities use the coordinator pattern and do not subscribe to + integration-specific events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register any service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Modbus TCP has no authentication mechanism. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + Modbus TCP gateways have no standard discovery protocol + (no zeroconf, SSDP, or DHCP signal that uniquely identifies + a NeoPool controller behind the gateway). + discovery-update-info: + status: exempt + comment: See discovery exemption above. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + One config entry maps to one physical NeoPool controller; multiple + controllers are supported via separate config entries. The single + device per entry is created during initial setup and cannot change. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + One config entry maps to one physical device; the device is not + removed during runtime, so there are no stale devices to clean up. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Integration uses Modbus TCP, not HTTP, so no aiohttp session is involved. + strict-typing: done diff --git a/homeassistant/components/neopool/sensor.py b/homeassistant/components/neopool/sensor.py new file mode 100644 index 000000000000..830628fb73e3 --- /dev/null +++ b/homeassistant/components/neopool/sensor.py @@ -0,0 +1,380 @@ +"""Sensor platform for the NeoPool integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Any, override + +from neopool_modbus.capabilities import ( + has_filtvalve, + has_heating_relay, + has_variable_speed_pump, + is_chlorine_module_present, + is_conductivity_module_present, + is_hydrolysis_present, + is_ionization_present, + is_ph_module_present, + is_redox_module_present, + is_temperature_active, +) +from neopool_modbus.decoders import ( + FILTRATION_MODE_LABELS, + FILTRATION_SPEED_STATE_LABELS, + HIDRO_POLARITY_LABELS, + ION_POLARITY_LABELS, + PH_STATUS_ALARM_LABELS, + calculate_next_interval_time, + decode_hidro_polarity, + decode_ion_polarity, + decode_ph_alarm, + decode_ph_pump_status, + is_hydrolysis_in_percent, + ph_pump_options, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricPotential, + UnitOfRatio, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NeoPoolConfigEntry +from .coordinator import NeoPoolCoordinator +from .entity import NeoPoolEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class NeoPoolSensorEntityDescription(SensorEntityDescription): + """Describes a NeoPool sensor entity.""" + + supported_fn: Callable[[dict[str, Any]], bool] | None = None + value_fn: Callable[[dict[str, Any]], Any] | None = None + options_fn: Callable[[dict[str, Any]], list[str]] | None = None + unit_fn: Callable[[dict[str, Any]], str | None] | None = None + precision_fn: Callable[[dict[str, Any]], int | None] | None = None + + +SENSOR_DESCRIPTIONS: dict[str, NeoPoolSensorEntityDescription] = { + "MBF_ION_CURRENT": NeoPoolSensorEntityDescription( + key="MBF_ION_CURRENT", + translation_key="ion_current", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_ionization_present, + ), + "MBF_HIDRO_CURRENT": NeoPoolSensorEntityDescription( + key="MBF_HIDRO_CURRENT", + translation_key="hidro_current", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=is_hydrolysis_present, + unit_fn=lambda data: ( + UnitOfRatio.PERCENTAGE if is_hydrolysis_in_percent(data) else "g/h" + ), + precision_fn=lambda data: 0 if is_hydrolysis_in_percent(data) else 1, + ), + "MBF_MEASURE_PH": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_PH", + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_ph_module_present, + ), + "MBF_MEASURE_RX": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_RX", + translation_key="measure_rx", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_redox_module_present, + ), + "MBF_MEASURE_CL": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_CL", + translation_key="measure_cl", + native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_chlorine_module_present, + ), + "MBF_MEASURE_CONDUCTIVITY": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_CONDUCTIVITY", + translation_key="measure_conductivity", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=is_conductivity_module_present, + ), + "MBF_MEASURE_TEMPERATURE": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_TEMPERATURE", + translation_key="measure_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_temperature_active, + ), + "MBF_HIDRO_VOLTAGE": NeoPoolSensorEntityDescription( + key="MBF_HIDRO_VOLTAGE", + translation_key="hidro_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "MBF_PAR_FILT_MODE": NeoPoolSensorEntityDescription( + key="MBF_PAR_FILT_MODE", + translation_key="filt_mode", + device_class=SensorDeviceClass.ENUM, + options=list(FILTRATION_MODE_LABELS.values()), + value_fn=lambda data: data.get("filtration_mode"), + ), + "MBF_PH_STATUS_ALARM": NeoPoolSensorEntityDescription( + key="MBF_PH_STATUS_ALARM", + translation_key="ph_status_alarm", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(PH_STATUS_ALARM_LABELS.values()), + value_fn=decode_ph_alarm, + supported_fn=is_ph_module_present, + ), + "HIDRO_POLARITY": NeoPoolSensorEntityDescription( + key="HIDRO_POLARITY", + translation_key="hidro_polarity", + device_class=SensorDeviceClass.ENUM, + options=list(HIDRO_POLARITY_LABELS), + value_fn=decode_hidro_polarity, + supported_fn=is_hydrolysis_present, + ), + "ION_POLARITY": NeoPoolSensorEntityDescription( + key="ION_POLARITY", + translation_key="ion_polarity", + device_class=SensorDeviceClass.ENUM, + options=list(ION_POLARITY_LABELS), + value_fn=decode_ion_polarity, + supported_fn=is_ionization_present, + ), + "PH_PUMP_STATUS": NeoPoolSensorEntityDescription( + key="PH_PUMP_STATUS", + translation_key="ph_pump_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options_fn=ph_pump_options, + value_fn=decode_ph_pump_status, + supported_fn=is_ph_module_present, + ), + "FILTRATION_SPEED": NeoPoolSensorEntityDescription( + key="FILTRATION_SPEED", + translation_key="filtration_speed", + device_class=SensorDeviceClass.ENUM, + options=list(FILTRATION_SPEED_STATE_LABELS), + value_fn=lambda data: data.get("filtration_speed_state"), + supported_fn=has_variable_speed_pump, + ), + "MBF_PAR_INTELLIGENT_INTERVALS": NeoPoolSensorEntityDescription( + key="MBF_PAR_INTELLIGENT_INTERVALS", + translation_key="intelligent_intervals", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=lambda data: ( + has_heating_relay(data) and is_temperature_active(data) + ), + ), + "MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL": NeoPoolSensorEntityDescription( + key="MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL", + translation_key="intelligent_tt_next_interval", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: calculate_next_interval_time( + data.get("MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL") + ), + supported_fn=lambda data: ( + has_heating_relay(data) and is_temperature_active(data) + ), + ), + "MBF_PAR_FILTVALVE_REMAINING": NeoPoolSensorEntityDescription( + key="MBF_PAR_FILTVALVE_REMAINING", + translation_key="filtvalve_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=has_filtvalve, + ), + "CELL_RUNTIME_TOTAL": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_TOTAL", + translation_key="cell_runtime_total", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_PART": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_PART", + translation_key="cell_runtime_part", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POLA": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POLA", + translation_key="cell_runtime_pola", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POLB": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POLB", + translation_key="cell_runtime_polb", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POL_CHANGES": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POL_CHANGES", + translation_key="cell_runtime_pol_changes", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NeoPoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NeoPool sensors from a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + NeoPoolSensor(coordinator, key, desc) + for key, desc in SENSOR_DESCRIPTIONS.items() + if desc.supported_fn is None or desc.supported_fn(coordinator.data) + ) + + +_PRODUCTION_KEYS_REQUIRING_FILTRATION = frozenset( + { + "MBF_HIDRO_CURRENT", + "MBF_HIDRO_VOLTAGE", + "MBF_ION_CURRENT", + } +) + +_MEASURE_KEYS_REQUIRING_FILTRATION = frozenset( + { + "MBF_MEASURE_TEMPERATURE", + "MBF_MEASURE_PH", + "MBF_MEASURE_RX", + "MBF_MEASURE_CL", + "MBF_MEASURE_CONDUCTIVITY", + } +) + + +class NeoPoolSensor(NeoPoolEntity, SensorEntity): + """Representation of a NeoPool sensor.""" + + entity_description: NeoPoolSensorEntityDescription + + def __init__( + self, + coordinator: NeoPoolCoordinator, + key: str, + description: NeoPoolSensorEntityDescription, + ) -> None: + """Initialize the NeoPool sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._key = key + self._attr_unique_id = ( + f"{self.coordinator.config_entry.unique_id}_{key.lower()}" + ) + + @property + @override + def suggested_display_precision(self) -> int | None: + """Return the suggested display precision for the sensor value.""" + if (precision_fn := self.entity_description.precision_fn) is not None: + return precision_fn(self.coordinator.data) + return super().suggested_display_precision + + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement for the sensor value.""" + if (unit_fn := self.entity_description.unit_fn) is not None: + return unit_fn(self.coordinator.data) + return super().native_unit_of_measurement + + def _filtration_off(self) -> bool: + """Return True when the filtration pump is off.""" + return self.coordinator.data.get("Filtration Pump") is False + + def _is_measurement_suppressed(self) -> bool: + """Return True if a measurement sensor should report None.""" + if self._key not in _MEASURE_KEYS_REQUIRING_FILTRATION: + return False + return self._filtration_off() + + def _is_production_suppressed(self) -> bool: + """Return True if a production sensor should report 0.""" + if self._key not in _PRODUCTION_KEYS_REQUIRING_FILTRATION: + return False + return self._filtration_off() + + @property + @override + def native_value(self) -> float | int | str | datetime | None: + """Return the actual sensor value from coordinator data.""" + if self._is_measurement_suppressed(): + return None + if self._is_production_suppressed(): + return 0 + if (value_fn := self.entity_description.value_fn) is not None: + value: float | int | str | datetime | None = value_fn(self.coordinator.data) + return value + return self.coordinator.data.get(self._key) + + @property + @override + def options(self) -> list[str] | None: + """Return the list of options for the sensor.""" + if (options_fn := self.entity_description.options_fn) is not None: + return options_fn(self.coordinator.data) + return super().options diff --git a/homeassistant/components/neopool/strings.json b/homeassistant/components/neopool/strings.json new file mode 100644 index 000000000000..4f3bea3e33d8 --- /dev/null +++ b/homeassistant/components/neopool/strings.json @@ -0,0 +1,150 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_read_modbus": "Connected, but cannot read from the Modbus device. Check unit ID and framer settings." + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "modbus_framer": "Modbus framer", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "Unit ID" + }, + "data_description": { + "host": "Enter the IP address of the Modbus TCP gateway connected to your pool controller.", + "modbus_framer": "Wire protocol used by the gateway. Select TCP for MBAP framing (default for Ethernet-native gateways). Select RTU for RTU framing tunnelled through a TCP socket, used by passthrough serial-to-TCP bridges like ESPHome's stream_server.", + "port": "Standard Modbus TCP port (default: 502). Change only if your gateway uses a non-standard port.", + "unit_id": "Modbus device address (1-247). Identifies the specific device on a shared bus. Most direct-connected devices use 1. Gateways may route requests to different addresses." + }, + "description": "Configure the connection to your NeoPool controller.", + "title": "NeoPool Connection" + } + } + }, + "entity": { + "sensor": { + "cell_runtime_part": { + "name": "Cell runtime since reset" + }, + "cell_runtime_pol_changes": { + "name": "Cell polarity changes" + }, + "cell_runtime_pola": { + "name": "Cell runtime in polarity 1" + }, + "cell_runtime_polb": { + "name": "Cell runtime in polarity 2" + }, + "cell_runtime_total": { + "name": "Cell runtime total" + }, + "filt_mode": { + "name": "Filtration mode", + "state": { + "auto": "Automatic", + "backwash": "Backwash", + "heating": "Heating", + "intelligent": "Intelligent", + "manual": "[%key:common::state::manual%]", + "smart": "Smart" + } + }, + "filtration_speed": { + "name": "Current filtration speed", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "mid": "Medium", + "off": "[%key:common::state::off%]" + } + }, + "filtvalve_remaining": { + "name": "Backwash time remaining" + }, + "hidro_current": { + "name": "Hydrolysis intensity" + }, + "hidro_polarity": { + "name": "Hydrolysis polarity", + "state": { + "dead_time": "Dead time", + "no_flow": "No flow", + "off": "[%key:common::state::off%]", + "pol1": "Polarity 1", + "pol2": "Polarity 2" + } + }, + "hidro_voltage": { + "name": "Hydrolysis voltage" + }, + "intelligent_intervals": { + "name": "Intelligent mode intervals" + }, + "intelligent_tt_next_interval": { + "name": "Intelligent mode next interval start" + }, + "ion_current": { + "name": "Ionization level" + }, + "ion_polarity": { + "name": "Ionizer polarity", + "state": { + "dead_time": "Dead time", + "off": "[%key:common::state::off%]", + "pol1": "Polarity 1", + "pol2": "Polarity 2" + } + }, + "measure_cl": { + "name": "Salt level" + }, + "measure_conductivity": { + "name": "Conductivity level" + }, + "measure_rx": { + "name": "Redox potential" + }, + "measure_temperature": { + "name": "Water temperature" + }, + "ph_pump_status": { + "name": "pH pump status", + "state": { + "acid": "Acid pump", + "base": "Base pump", + "both": "Both pumps", + "idle": "[%key:common::state::idle%]", + "off": "[%key:common::state::off%]" + } + }, + "ph_status_alarm": { + "name": "pH alarm", + "state": { + "ok": "OK", + "ph_high": "pH too high", + "ph_low": "pH too low", + "ph_over": "pH higher than the set point", + "ph_under": "pH lower than the set point", + "pump_stopped": "Pump stopped (exceeded working time)", + "tank_level": "Tank level alarm" + } + } + } + }, + "exceptions": { + "modbus_communication_error": { + "message": "An error occurred while communicating with the NeoPool controller: {error}" + } + }, + "issues": { + "corrupted_gpio": { + "description": "The following GPIO register(s) on your pool controller contain invalid values:\n\n{details}\n\nThis typically happens when the Modbus gateway framing mode does not match the integration's framer setting. The affected function(s) will not work correctly until the register(s) are restored to valid values.\n\nSee the integration documentation for repair instructions.", + "title": "Corrupted GPIO register(s) detected" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b3be40e3696e..4d0256cc4f05 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -498,6 +498,7 @@ FLOWS = { "nasweb", "neato", "nederlandse_spoorwegen", + "neopool", "ness_alarm", "nest", "netatmo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 361b332c997b..a9be4c9cf706 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4630,6 +4630,12 @@ "integration_type": "virtual", "supported_by": "shelly" }, + "neopool": { + "name": "NeoPool", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 77911105e280..a8fea92f7875 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3707,6 +3707,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.neopool.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.nest.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d47877ce7de3..3dfcf2642cfd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1639,6 +1639,9 @@ nad-receiver==0.3.0 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 +# homeassistant.components.neopool +neopool-modbus==3.6.0 + # homeassistant.components.ness_alarm nessclient==1.3.1 diff --git a/tests/components/neopool/__init__.py b/tests/components/neopool/__init__.py new file mode 100644 index 000000000000..327810d614a2 --- /dev/null +++ b/tests/components/neopool/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the NeoPool integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the NeoPool integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/neopool/conftest.py b/tests/components/neopool/conftest.py new file mode 100644 index 000000000000..4ef31e0dfeb9 --- /dev/null +++ b/tests/components/neopool/conftest.py @@ -0,0 +1,175 @@ +"""Common fixtures for the NeoPool tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.neopool.const import ( + CURRENT_VERSION, + DEFAULT_PORT, + DEFAULT_UNIT_ID, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from tests.common import MockConfigEntry + +MOCK_HOST = "192.0.2.1" +MOCK_PORT = DEFAULT_PORT +MOCK_NAME = "Pool" +MOCK_SERIAL = "1234567890" + + +MOCK_POOL_DATA: dict[str, Any] = { + "MBF_POWER_MODULE_VERSION": 0x1234, + "MBF_PAR_VERSION": 0x100, + "MBF_PAR_MODEL": 0x0003, + "MBF_PAR_SERNUM": int(MOCK_SERIAL), + "MBF_PAR_FILTRATION_CONF": 1, + "MBF_PAR_FILT_GPIO": 1, + "MBF_PAR_LIGHTING_GPIO": 2, + "MBF_PAR_HEATING_GPIO": 3, + "MBF_PAR_PH_ACID_RELAY_GPIO": 4, + "MBF_PAR_PH_BASE_RELAY_GPIO": 5, + "MBF_PAR_RX_RELAY_GPIO": 6, + "MBF_PAR_CL_RELAY_GPIO": 7, + "MBF_PAR_CD_RELAY_GPIO": 0, + "MBF_PAR_UV_RELAY_GPIO": 1, + "MBF_PAR_FILTVALVE_GPIO": 1, + "MBF_PAR_FILTVALVE_ENABLE": 1, + "MBF_PAR_TEMPERATURE_ACTIVE": 1, + "MBF_PAR_UICFG_MACHINE": 0, + "MBF_PAR_RELAY_PH": 0, + "Hydrolysis module detected": True, + "Redox measurement module detected": True, + "pH measurement module detected": True, + "Chlorine measurement module detected": True, + "Conductivity measurement module detected": True, + "Ionization module detected": True, + "MBF_PAR_FILT_MODE": 0, + "filtration_mode": "manual", + "filtration_speed_state": "off", + "MBF_MEASURE_TEMPERATURE": 250, + "MBF_MEASURE_PH": 720, + "MBF_MEASURE_RX": 650, + "MBF_MEASURE_CL": 120, + "MBF_MEASURE_CONDUCTIVITY": 45, + "MBF_HIDRO_CURRENT": 70, + "MBF_HIDRO_VOLTAGE": 24, + "MBF_ION_CURRENT": 50, + "MBF_PAR_INTELLIGENT_INTERVALS": 4, + "MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL": 7200, + "MBF_PAR_FILTVALVE_REMAINING": 0, + "HIDRO_POLARITY": 0, + "ION_POLARITY": 0, + "PH_PUMP_STATUS": "off", + "HIDRO in Pol1": False, + "HIDRO in Pol2": False, + "HIDRO in dead time": False, + "ION in Pol1": False, + "ION in Pol2": False, + "ION in dead time": False, + "pH control module": True, + "pH pump active": False, + "pH acid pump active": False, + "Filtration Pump": False, + "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19, + "Pool Cover": 0, + "CELL_RUNTIME_TOTAL": 0x00010000, + "CELL_RUNTIME_PART": 0x00000E10, + "CELL_RUNTIME_POLA": 0x00000708, + "CELL_RUNTIME_POLB": 0x00000708, + "CELL_RUNTIME_POL_CHANGES": 0x00000007, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.neopool.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a config entry with a bare-serial unique_id.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_NAME, + unique_id=MOCK_SERIAL, + version=CURRENT_VERSION, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_NAME: MOCK_NAME, + "unit_id": DEFAULT_UNIT_ID, + "modbus_framer": "tcp", + }, + ) + + +@pytest.fixture +def mock_neopool_client() -> Generator[MagicMock]: + """Patch the NeoPoolModbusClient and return a configurable mock instance.""" + with ( + patch( + "homeassistant.components.neopool.NeoPoolModbusClient", + autospec=True, + ) as mock_client_cls, + patch( + "homeassistant.components.neopool.config_flow.async_probe_serial", + new=AsyncMock(return_value=MOCK_SERIAL), + ), + ): + mock_client = mock_client_cls.return_value + mock_client.async_read_all = AsyncMock(return_value=dict(MOCK_POOL_DATA)) + mock_client.close = AsyncMock() + yield mock_client + + +@pytest.fixture +def minimal_pool_data() -> dict[str, Any]: + """Pool data with all optional capability flags off. + + Used to drive the 'should-skip' branches for supported_fn gating. + """ + return { + "MBF_POWER_MODULE_VERSION": 0x1234, + "MBF_PAR_VERSION": 0x100, + "MBF_PAR_MODEL": 0, + "MBF_PAR_SERNUM": int(MOCK_SERIAL), + "MBF_PAR_FILTRATION_CONF": 0, + "MBF_PAR_FILT_GPIO": 0, + "MBF_PAR_LIGHTING_GPIO": 0, + "MBF_PAR_HEATING_GPIO": 0, + "MBF_PAR_PH_ACID_RELAY_GPIO": 0, + "MBF_PAR_PH_BASE_RELAY_GPIO": 0, + "MBF_PAR_RX_RELAY_GPIO": 0, + "MBF_PAR_CL_RELAY_GPIO": 0, + "MBF_PAR_CD_RELAY_GPIO": 0, + "MBF_PAR_UV_RELAY_GPIO": 0, + "MBF_PAR_FILTVALVE_GPIO": 0, + "MBF_PAR_FILTVALVE_ENABLE": 0, + "MBF_PAR_TEMPERATURE_ACTIVE": 0, + "Hydrolysis module detected": False, + "Redox measurement module detected": False, + "pH measurement module detected": False, + "MBF_PAR_FILT_MODE": 0, + "filtration_mode": "manual", + "filtration_speed_state": "off", + "Filtration Pump": False, + } + + +@pytest.fixture +def mock_socket_connection() -> Generator[AsyncMock]: + """Patch the lib probe in config_flow so we don't hit the network.""" + with patch( + "homeassistant.components.neopool.config_flow.async_probe_serial", + new=AsyncMock(return_value=MOCK_SERIAL), + ) as mock: + yield mock diff --git a/tests/components/neopool/snapshots/test_sensor.ambr b/tests/components/neopool/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..e830f3ba5cbe --- /dev/null +++ b/tests/components/neopool/snapshots/test_sensor.ambr @@ -0,0 +1,1379 @@ +# serializer version: 1 +# name: test_all_entities[sensor.neopool_backwash_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_backwash_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backwash time remaining', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backwash time remaining', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtvalve_remaining', + 'unique_id': '1234567890_mbf_par_filtvalve_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_backwash_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Backwash time remaining', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_backwash_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_polarity_changes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_polarity_changes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell polarity changes', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cell polarity changes', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_pol_changes', + 'unique_id': '1234567890_cell_runtime_pol_changes', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_cell_polarity_changes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Cell polarity changes', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_polarity_changes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime in polarity 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime in polarity 1', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_pola', + 'unique_id': '1234567890_cell_runtime_pola', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime in polarity 1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime in polarity 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime in polarity 2', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_polb', + 'unique_id': '1234567890_cell_runtime_polb', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime in polarity 2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_since_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_since_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime since reset', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime since reset', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_part', + 'unique_id': '1234567890_cell_runtime_part', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_since_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime since reset', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_since_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime total', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_total', + 'unique_id': '1234567890_cell_runtime_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2044444444444', + }) +# --- +# name: test_all_entities[sensor.neopool_conductivity_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_conductivity_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_conductivity', + 'unique_id': '1234567890_mbf_measure_conductivity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_conductivity_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Conductivity level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_conductivity_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_current_filtration_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'low', + 'mid', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_current_filtration_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current filtration speed', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current filtration speed', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_speed', + 'unique_id': '1234567890_filtration_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_current_filtration_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Current filtration speed', + : list([ + 'off', + 'low', + 'mid', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_current_filtration_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_filtration_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_filtration_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration mode', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filt_mode', + 'unique_id': '1234567890_mbf_par_filt_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_filtration_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Filtration mode', + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_filtration_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_hydrolysis_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis intensity', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hydrolysis intensity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_current', + 'unique_id': '1234567890_mbf_hidro_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Hydrolysis intensity', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_polarity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'no_flow', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_hydrolysis_polarity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis polarity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrolysis polarity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_polarity', + 'unique_id': '1234567890_hidro_polarity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_polarity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Hydrolysis polarity', + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'no_flow', + 'off', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_polarity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_hydrolysis_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrolysis voltage', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_voltage', + 'unique_id': '1234567890_mbf_hidro_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'NeoPool Hydrolysis voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_intervals-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_intelligent_mode_intervals', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intelligent mode intervals', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intelligent mode intervals', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intelligent_intervals', + 'unique_id': '1234567890_mbf_par_intelligent_intervals', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_intervals-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Intelligent mode intervals', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_intelligent_mode_intervals', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_next_interval_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_intelligent_mode_next_interval_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intelligent mode next interval start', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intelligent mode next interval start', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intelligent_tt_next_interval', + 'unique_id': '1234567890_mbf_par_intelligent_tt_next_interval', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_next_interval_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'NeoPool Intelligent mode next interval start', + }), + 'context': , + 'entity_id': 'sensor.neopool_intelligent_mode_next_interval_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-07-03T14:00:00+00:00', + }) +# --- +# name: test_all_entities[sensor.neopool_ionization_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ionization_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ionization level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ionization level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ion_current', + 'unique_id': '1234567890_mbf_ion_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_ionization_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Ionization level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_ionization_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_ionizer_polarity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ionizer_polarity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ionizer polarity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ionizer polarity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ion_polarity', + 'unique_id': '1234567890_ion_polarity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ionizer_polarity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Ionizer polarity', + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'off', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ionizer_polarity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_mbf_measure_ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ph', + : 'NeoPool pH', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_ph_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'ok', + 'ph_high', + 'ph_low', + 'pump_stopped', + 'ph_over', + 'ph_under', + 'tank_level', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_ph_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH alarm', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_status_alarm', + 'unique_id': '1234567890_mbf_ph_status_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool pH alarm', + : list([ + 'ok', + 'ph_high', + 'ph_low', + 'pump_stopped', + 'ph_over', + 'ph_under', + 'tank_level', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ph_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_ph_pump_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'idle', + 'acid', + 'base', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_ph_pump_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH pump status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH pump status', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_pump_status', + 'unique_id': '1234567890_ph_pump_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph_pump_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool pH pump status', + : list([ + 'off', + 'idle', + 'acid', + 'base', + 'both', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ph_pump_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[sensor.neopool_redox_potential-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_redox_potential', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox potential', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox potential', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_rx', + 'unique_id': '1234567890_mbf_measure_rx', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_redox_potential-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'NeoPool Redox potential', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_redox_potential', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_salt_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_salt_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_cl', + 'unique_id': '1234567890_mbf_measure_cl', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_salt_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Salt level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_salt_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_temperature', + 'unique_id': '1234567890_mbf_measure_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'NeoPool Water temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_when_modules_absent[sensor.neopool_filtration_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_filtration_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration mode', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filt_mode', + 'unique_id': '1234567890_mbf_par_filt_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_when_modules_absent[sensor.neopool_filtration_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Filtration mode', + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_filtration_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- diff --git a/tests/components/neopool/test_config_flow.py b/tests/components/neopool/test_config_flow.py new file mode 100644 index 000000000000..96ecb532914f --- /dev/null +++ b/tests/components/neopool/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the NeoPool config flow.""" + +from unittest.mock import AsyncMock + +from neopool_modbus.exceptions import ( + NeoPoolConnectionError, + NeoPoolModbusError, + NeoPoolTimeoutError, +) +import pytest + +from homeassistant.components.neopool.const import DEFAULT_UNIT_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_HOST, MOCK_PORT, MOCK_SERIAL + +from tests.common import MockConfigEntry + +USER_INPUT = { + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + "unit_id": DEFAULT_UNIT_ID, + "modbus_framer": "tcp", +} + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test a happy-path config flow creates the entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_HOST + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["result"].unique_id == MOCK_SERIAL + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + ("exc_cls", "error_key"), + [ + (NeoPoolConnectionError, "cannot_connect"), + (NeoPoolTimeoutError, "cannot_connect"), + (NeoPoolModbusError, "cannot_read_modbus"), + ], +) +async def test_user_flow_probe_errors_recover( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_socket_connection: AsyncMock, + exc_cls: type[Exception], + error_key: str, +) -> None: + """Probe errors surface as form errors, and the flow recovers on retry.""" + mock_socket_connection.side_effect = exc_cls("boom") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: error_key} + + mock_socket_connection.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when the same device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/neopool/test_init.py b/tests/components/neopool/test_init.py new file mode 100644 index 000000000000..8ef873df5f89 --- /dev/null +++ b/tests/components/neopool/test_init.py @@ -0,0 +1,192 @@ +"""Test the NeoPool integration setup, unload, and lifecycle.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +from neopool_modbus.registers import MAX_RELAY_GPIO +import pytest + +from homeassistant.components.neopool.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir + +from . import setup_integration +from .conftest import MOCK_POOL_DATA, MOCK_SERIAL + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Set up the integration end-to-end and tear it down again.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_first_refresh_fails_marks_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, +) -> None: + """Setup re-tries when the first Modbus read raises.""" + mock_neopool_client.async_read_all = AsyncMock( + side_effect=ConnectionError("Modbus down") + ) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_device_registered_with_firmware( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The first successful read populates firmware on the device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) + assert device is not None + assert "18.52" in (device.sw_version or "") + + +async def test_transient_modbus_failure_after_first_success_marks_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Entities transition to unavailable when polling fails after a good read.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.neopool_water_temperature" + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + mock_neopool_client.async_read_all.side_effect = ConnectionError("Modbus fail") + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_corrupt_gpio_creates_repair_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """A GPIO register outside 0..MAX_RELAY_GPIO opens a corrupted_gpio issue.""" + bad_data = dict(MOCK_POOL_DATA) + bad_data["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=bad_data) + + await setup_integration(hass, mock_config_entry) + + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.severity is ir.IssueSeverity.ERROR + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_clean_gpio_does_not_create_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """A clean read does not open a corrupted_gpio issue.""" + await setup_integration(hass, mock_config_entry) + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_corrupt_gpio_clears_stale_issue_from_previous_session( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """A stale issue from a previous session clears on the first clean poll.""" + ir.async_create_issue( + hass, + DOMAIN, + "corrupted_gpio", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="corrupted_gpio", + translation_placeholders={"details": "- stale"}, + ) + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is not None + + await setup_integration(hass, mock_config_entry) + + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +async def test_corrupt_gpio_logs_error_only_on_state_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """The ERROR log fires on entering corruption and clears on healing.""" + bad_data = dict(MOCK_POOL_DATA) + bad_data["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=bad_data) + + await setup_integration(hass, mock_config_entry) + assert sum("Corrupted GPIO register" in r.message for r in caplog.records) == 1 + + caplog.clear() + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert not any("Corrupted GPIO register" in r.message for r in caplog.records) + + mock_neopool_client.async_read_all = AsyncMock(return_value=dict(MOCK_POOL_DATA)) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +async def test_corrupt_gpio_updates_issue_on_value_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """The repair issue details refresh when a corrupted register value changes.""" + first = dict(MOCK_POOL_DATA) + first["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=first) + + await setup_integration(hass, mock_config_entry) + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.translation_placeholders is not None + assert str(MAX_RELAY_GPIO + 1) in issue.translation_placeholders["details"] + + second = dict(MOCK_POOL_DATA) + second["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 2 + mock_neopool_client.async_read_all = AsyncMock(return_value=second) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.translation_placeholders is not None + assert str(MAX_RELAY_GPIO + 2) in issue.translation_placeholders["details"] diff --git a/tests/components/neopool/test_sensor.py b/tests/components/neopool/test_sensor.py new file mode 100644 index 000000000000..4e925f15ae95 --- /dev/null +++ b/tests/components/neopool/test_sensor.py @@ -0,0 +1,242 @@ +"""Tests for the NeoPool sensor platform.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import ATTR_OPTIONS +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import MOCK_POOL_DATA + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_measurement_sensors_suppressed_when_filtration_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Probe sensors report unknown while filtration pump is off (stale reading).""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "Filtration Pump": False, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in ( + "sensor.neopool_ph", + "sensor.neopool_redox_potential", + "sensor.neopool_water_temperature", + ): + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "unknown" + + +async def test_production_sensors_zero_when_filtration_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Production sensors report 0 while filtration pump is off (cell idle).""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "Filtration Pump": False, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in ( + "sensor.neopool_hydrolysis_intensity", + "sensor.neopool_ionization_level", + ): + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "0" + + +@pytest.mark.parametrize( + ("filt_mode", "expected"), + [ + (0, "manual"), + (1, "auto"), + (2, "heating"), + (3, "smart"), + (4, "intelligent"), + (13, "backwash"), + ], +) +async def test_filt_mode_native_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + filt_mode: int, + expected: str, +) -> None: + """Filt mode native value reads the lib's decoded filtration_mode key.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_FILT_MODE": filt_mode, + "filtration_mode": expected, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.neopool_filtration_mode") + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("relay", "expected_options"), + [ + pytest.param(1, ["off", "idle", "acid"], id="acid_only"), + pytest.param(2, ["off", "idle", "base"], id="base_only"), + pytest.param(0, ["off", "idle", "acid", "base", "both"], id="both_relays"), + ], +) +async def test_ph_pump_status_options_per_relay_config( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + relay: int, + expected_options: list[str], +) -> None: + """The pH pump status options list shrinks based on the relay configuration.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_RELAY_PH": relay, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.neopool_ph_pump_status") + assert state is not None + assert state.attributes[ATTR_OPTIONS] == expected_options + + +async def test_hidro_current_g_per_hour_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """In g/h mode HIDRO_CURRENT swaps unit and bumps display precision.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_UICFG_MACHINE": 1, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.neopool_hydrolysis_intensity") + assert state is not None + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "g/h" + + +_CELL_RUNTIME_ENTITY_IDS: dict[str, str] = { + "CELL_RUNTIME_TOTAL": "sensor.neopool_cell_runtime_total", + "CELL_RUNTIME_PART": "sensor.neopool_cell_runtime_since_reset", + "CELL_RUNTIME_POLA": "sensor.neopool_cell_runtime_in_polarity_1", + "CELL_RUNTIME_POLB": "sensor.neopool_cell_runtime_in_polarity_2", + "CELL_RUNTIME_POL_CHANGES": "sensor.neopool_cell_polarity_changes", +} + + +@pytest.mark.parametrize( + ("key", "expected_seconds"), + [ + ("CELL_RUNTIME_TOTAL", 65536), + ("CELL_RUNTIME_PART", 3600), + ("CELL_RUNTIME_POLA", 1800), + ("CELL_RUNTIME_POLB", 1800), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_cell_runtime_duration_sensor_reads_combined_register( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + key: str, + expected_seconds: int, +) -> None: + """Each duration CELL_RUNTIME_* sensor reads the combined u32 key from coordinator data. + + Sensors have ``entity_registry_enabled_default=False``; enabling every + disabled-by-default entity via the fixture avoids the reload dance. The + sensors declare seconds but suggest hours, so ``state.state`` is expressed + in hours (converted by the frontend layer). + """ + await setup_integration(hass, mock_config_entry) + + entity_id = _CELL_RUNTIME_ENTITY_IDS[key] + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert float(state.state) == pytest.approx(expected_seconds / 3600, abs=1e-4) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_cell_runtime_pol_changes_sensor_reads_combined_register( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """CELL_RUNTIME_POL_CHANGES reads the combined u32 key as a raw counter. + + Unlike the duration sensors, this one has no unit and no unit conversion, + so ``state.state`` is the raw integer from coordinator data. + """ + await setup_integration(hass, mock_config_entry) + + entity_id = _CELL_RUNTIME_ENTITY_IDS["CELL_RUNTIME_POL_CHANGES"] + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "7" + + +@pytest.mark.freeze_time("2026-07-03T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Snapshot every entity registered by the sensor platform.""" + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2026-07-03T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup_when_modules_absent( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + minimal_pool_data: dict[str, Any], +) -> None: + """Snapshot the sensor entities registered when no modules are present.""" + mock_neopool_client.async_read_all.return_value = minimal_pool_data + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From b440d798d0fef58fd3b441a337efa2b993e3e26f Mon Sep 17 00:00:00 2001 From: hplato Date: Tue, 7 Jul 2026 08:48:38 -0600 Subject: [PATCH 189/707] Update Venstar Colortouch to increase padding in firmware display. (#175774) --- homeassistant/components/venstar/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/venstar/entity.py b/homeassistant/components/venstar/entity.py index 845e37cd5b43..5beef4585127 100644 --- a/homeassistant/components/venstar/entity.py +++ b/homeassistant/components/venstar/entity.py @@ -41,5 +41,5 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): name=self._client.name, manufacturer="Venstar", model=f"{self._client.model}-{self._client.get_type()}", - sw_version=f"{firmware_version[0]}.{firmware_version[1]}", + sw_version=f"{firmware_version[0]}.{firmware_version[1]:02}", ) From dec0c67bb255809675bedd4c6d8384161a2d4a5e Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Tue, 7 Jul 2026 16:51:40 +0200 Subject: [PATCH 190/707] Fix stiebel_eltron climate action exceptions to comply with action-exceptions rule (#175792) Co-authored-by: Claude Sonnet 4.6 --- .../components/stiebel_eltron/climate.py | 5 +-- .../components/stiebel_eltron/manifest.json | 2 +- .../stiebel_eltron/quality_scale.yaml | 2 +- .../components/stiebel_eltron/test_climate.py | 35 +------------------ 4 files changed, 4 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 047fd029c738..493b0818db55 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -131,8 +131,6 @@ class StiebelEltron(StiebelEltronEntity, ClimateEntity): @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - if self.preset_mode: - return new_mode = HA_TO_LWZ_HVAC[hvac_mode] _LOGGER.debug("async_set_hvac_mode: %s -> %s", self._attr_hvac_mode, new_mode) try: @@ -145,8 +143,7 @@ class StiebelEltron(StiebelEltronEntity, ClimateEntity): @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (target_temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - raise HomeAssistantError("target temperature must be provided") + target_temperature = kwargs[ATTR_TEMPERATURE] _LOGGER.debug("async_set_temperature: %s", target_temperature) try: await self.coordinator.api_client.set_target_temp(target_temperature) diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index ab19f70dc13f..21f7e859b03c 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["pystiebeleltron==0.2.5"] } diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml index 183845abfa13..24a0036d9fac 100644 --- a/homeassistant/components/stiebel_eltron/quality_scale.yaml +++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml @@ -30,7 +30,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/tests/components/stiebel_eltron/test_climate.py b/tests/components/stiebel_eltron/test_climate.py index d24f4650a902..5ee6647a2ba0 100644 --- a/tests/components/stiebel_eltron/test_climate.py +++ b/tests/components/stiebel_eltron/test_climate.py @@ -22,7 +22,6 @@ from homeassistant.components.stiebel_eltron.climate import ( PRESET_READY, PRESET_WATER_HEATING, ) -from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -105,58 +104,27 @@ async def test_climate_entity_operating_modes( assert state.attributes[ATTR_PRESET_MODE] == expected_preset -async def test_climate_entity_set_hvac_mode_without_preset( +async def test_climate_entity_set_hvac_mode( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lwz_api: MagicMock, ) -> None: """Test setting HVAC mode.""" - - # Prepare mock - mock_lwz_api.get_operation.return_value = None - await _setup_integration(hass, mock_config_entry) - # Ensure no preset is active - state = hass.states.get(CLIMATE_ENTITY_ID) - assert state is not None - assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_PRESET_MODE) is None - - # Test setting to AUTO mock_lwz_api.set_operation.reset_mock() await async_set_hvac_mode(hass, HVACMode.AUTO, CLIMATE_ENTITY_ID) mock_lwz_api.set_operation.assert_awaited_with(OperatingMode.AUTOMATIC) - # Test setting to HEAT mock_lwz_api.set_operation.reset_mock() await async_set_hvac_mode(hass, HVACMode.HEAT, CLIMATE_ENTITY_ID) mock_lwz_api.set_operation.assert_awaited_with(OperatingMode.MANUAL_MODE) - # Test setting to OFF mock_lwz_api.set_operation.reset_mock() await async_set_hvac_mode(hass, HVACMode.OFF, CLIMATE_ENTITY_ID) mock_lwz_api.set_operation.assert_awaited_with(OperatingMode.DHW) -async def test_climate_entity_set_hvac_mode_with_preset( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_lwz_api: MagicMock, -) -> None: - """Test that setting HVAC mode does nothing when a preset is active.""" - - # Prepare mock - mock_lwz_api.get_operation.return_value = OperatingMode.DAY_MODE - mock_lwz_api.set_operation.reset_mock() - - await _setup_integration(hass, mock_config_entry) - - # Should not call set_operation when preset is active - await async_set_hvac_mode(hass, HVACMode.HEAT, CLIMATE_ENTITY_ID) - mock_lwz_api.set_operation.assert_not_called() - - async def test_climate_entity_set_temperature( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -175,7 +143,6 @@ async def test_climate_entity_set_hvac_mode_handles_api_exception( mock_lwz_api: MagicMock, ) -> None: """Test setting HVAC mode handles API exception.""" - mock_lwz_api.get_operation.return_value = None await _setup_integration(hass, mock_config_entry) mock_lwz_api.set_operation.side_effect = ModbusException("write failed") From 3e06799f51205a16d2a3118c5e24d3509b273b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 7 Jul 2026 16:08:40 +0100 Subject: [PATCH 191/707] Add github PR comments skill and use it in pr reviewer skill (#175790) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .claude/skills/ha-pr-comment-audit/SKILL.md | 17 +++++++++++++++++ .claude/skills/ha-pr-reviewer/SKILL.md | 10 +++++----- 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/ha-pr-comment-audit/SKILL.md diff --git a/.claude/skills/ha-pr-comment-audit/SKILL.md b/.claude/skills/ha-pr-comment-audit/SKILL.md new file mode 100644 index 000000000000..d949cbbe6e74 --- /dev/null +++ b/.claude/skills/ha-pr-comment-audit/SKILL.md @@ -0,0 +1,17 @@ +--- +name: ha-pr-comment-audit +description: Audits the review comment threads on a Home Assistant GitHub pull request, flagging unaddressed comments and requests for clarification. Use when checking whether PR feedback has been handled, either standalone or as part of a full PR review. +--- + +# Check Home Assistant PR Review Comments + +## Instructions: +- Resolve the PR context first. If a PR number is not given, use 'gh pr view' to identify the current branch's PR. +- Fetch the review comment threads for the PR (e.g. 'gh api' for review threads/comments). +- Flag comments that have not been addressed. If the author has replied but has not implemented the suggestion, still flag it and summarize the reply. +- Flag comments for which the author has asked for clarification. +- Generate a summary of the flagged comments, including a link for each comment. Don't include comments that have been addressed. + + +## IMPORTANT: +- Only provide feedback in the CONSOLE. DO NOT ACT ON GITHUB. diff --git a/.claude/skills/ha-pr-reviewer/SKILL.md b/.claude/skills/ha-pr-reviewer/SKILL.md index 370e6246725a..35c2ecd81781 100644 --- a/.claude/skills/ha-pr-reviewer/SKILL.md +++ b/.claude/skills/ha-pr-reviewer/SKILL.md @@ -5,11 +5,11 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c # Review GitHub Pull Request -## Follow these steps: -1. Use 'gh pr view' to get the PR details and description. -2. Use 'gh pr diff' to see all the changes in the PR. -3. Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. -4. Check if all existing review comments have been addressed. +## Instructions: +- Use 'gh pr view' to get the PR details and description. +- Use 'gh pr diff' to see all the changes in the PR. +- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. +- Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill. ## IMPORTANT: - Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB. From 411c2111e5f49320a8f30cfcc9206234ad6c6f55 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:03:58 +0200 Subject: [PATCH 192/707] Use state attribute enums in energy (#175869) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/energy/sensor.py | 38 ++++++++++++++------- homeassistant/components/energy/validate.py | 20 +++++++---- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index b893ed097880..85fd93d59fda 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -8,17 +8,17 @@ import logging from typing import Any, Final, Literal, cast, override from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - ATTR_STATE_CLASS, SensorDeviceClass, SensorEntity, + SensorEntityCapabilityAttribute, + SensorEntityStateAttribute, SensorStateClass, ) from homeassistant.components.sensor.recorder import ( # pylint: disable=home-assistant-component-root-import reset_detected, ) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, + EntityStateAttribute, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -436,7 +436,9 @@ class EnergyCostSensor(SensorEntity): if energy_state is None: return - state_class = energy_state.attributes.get(ATTR_STATE_CLASS) + state_class = energy_state.attributes.get( + SensorEntityCapabilityAttribute.STATE_CLASS + ) if state_class not in SUPPORTED_STATE_CLASSES: if not self._wrong_state_class_reported: self._wrong_state_class_reported = True @@ -450,7 +452,7 @@ class EnergyCostSensor(SensorEntity): # last_reset must be set if the sensor is SensorStateClass.MEASUREMENT if ( state_class == SensorStateClass.MEASUREMENT - and ATTR_LAST_RESET not in energy_state.attributes + and SensorEntityStateAttribute.LAST_RESET not in energy_state.attributes ): return @@ -478,22 +480,28 @@ class EnergyCostSensor(SensorEntity): if energy_price is None: return - energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + energy_unit: str | None = energy_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) if energy_unit is None or energy_unit not in valid_units: if not self._wrong_unit_reported: self._wrong_unit_reported = True _LOGGER.warning( "Found unexpected unit %s for %s", - energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + energy_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ), energy_state.entity_id, ) return if ( state_class != SensorStateClass.TOTAL_INCREASING - and energy_state.attributes.get(ATTR_LAST_RESET) - != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) + and energy_state.attributes.get(SensorEntityStateAttribute.LAST_RESET) + != self._last_energy_sensor_state.attributes.get( + SensorEntityStateAttribute.LAST_RESET + ) ) or ( state_class == SensorStateClass.TOTAL_INCREASING and reset_detected( @@ -544,7 +552,7 @@ class EnergyCostSensor(SensorEntity): energy_price = float(energy_price_state.state) energy_price_unit: str | None = energy_price_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, "" + EntityStateAttribute.UNIT_OF_MEASUREMENT, "" ).partition("/")[2] # For backwards compatibility we don't validate the unit of the price @@ -731,7 +739,7 @@ class EnergyPowerSensor(SensorEntity): return self._attr_native_unit_of_measurement = source_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) self._attr_native_value = value * -1 @@ -756,8 +764,12 @@ class EnergyPowerSensor(SensorEntity): return # Get units from state attributes - discharge_unit = discharge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + discharge_unit = discharge_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) + charge_unit = charge_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) # Convert to Watts if units are present if discharge_unit: diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index fe8eee2ba108..3c323c7884ea 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -6,9 +6,9 @@ import functools from homeassistant.components import recorder, sensor from homeassistant.const import ( - ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -231,13 +231,13 @@ def _async_validate_stat_common( if check_negative and current_value is not None and current_value < 0: issues.add_issue(hass, "entity_negative_state", entity_id, current_value) - device_class = state.attributes.get(ATTR_DEVICE_CLASS) + device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class not in allowed_device_classes: issues.add_issue( hass, "entity_unexpected_device_class", entity_id, device_class ) else: - unit = state.attributes.get("unit_of_measurement") + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if device_class and unit not in allowed_units.get(device_class, []): issues.add_issue(hass, unit_error, entity_id, unit) @@ -272,7 +272,9 @@ def _async_validate_usage_stat( state = hass.states.get(entity_id) assert state is not None - state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) + state_class = state.attributes.get( + sensor.SensorEntityCapabilityAttribute.STATE_CLASS + ) allowed_state_classes = [ sensor.SensorStateClass.MEASUREMENT, @@ -310,7 +312,7 @@ def _async_validate_price_entity( issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state) return - unit = state.attributes.get("unit_of_measurement") + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if unit is None or not unit.endswith(allowed_units): issues.add_issue(hass, unit_error, entity_id, unit) @@ -343,7 +345,9 @@ def _async_validate_power_stat( state = hass.states.get(entity_id) assert state is not None - state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) + state_class = state.attributes.get( + sensor.SensorEntityCapabilityAttribute.STATE_CLASS + ) if state_class != sensor.SensorStateClass.MEASUREMENT: issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class) @@ -372,7 +376,9 @@ def _async_validate_cost_stat( issues.add_issue(hass, "entity_not_defined", stat_id) return - state_class = state.attributes.get("state_class") + state_class = state.attributes.get( + sensor.SensorEntityCapabilityAttribute.STATE_CLASS + ) supported_state_classes = [ sensor.SensorStateClass.MEASUREMENT, From 21e51b83d3ae7bd7d8ddac9e4d4ba04002f0f138 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jul 2026 10:42:36 -0700 Subject: [PATCH 193/707] Add Google Health integration (#175417) Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../components/google_health/__init__.py | 88 ++++++ homeassistant/components/google_health/api.py | 42 +++ .../google_health/application_credentials.py | 23 ++ .../components/google_health/config_flow.py | 78 ++++++ .../components/google_health/const.py | 17 ++ .../components/google_health/coordinator.py | 176 ++++++++++++ .../components/google_health/icons.json | 12 + .../components/google_health/manifest.json | 12 + .../google_health/quality_scale.yaml | 88 ++++++ .../components/google_health/sensor.py | 138 ++++++++++ .../components/google_health/strings.json | 55 ++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/google_health/__init__.py | 1 + tests/components/google_health/conftest.py | 175 ++++++++++++ .../google_health/fixtures/distance.json | 11 + .../google_health/fixtures/identity.json | 5 + .../fixtures/resting_heart_rate.json | 10 + .../google_health/fixtures/steps.json | 11 + .../google_health/fixtures/userinfo.json | 5 + .../google_health/fixtures/weight.json | 12 + .../google_health/snapshots/test_sensor.ambr | 225 +++++++++++++++ .../google_health/test_config_flow.py | 256 ++++++++++++++++++ tests/components/google_health/test_init.py | 153 +++++++++++ tests/components/google_health/test_sensor.py | 48 ++++ 31 files changed, 1666 insertions(+) create mode 100644 homeassistant/components/google_health/__init__.py create mode 100644 homeassistant/components/google_health/api.py create mode 100644 homeassistant/components/google_health/application_credentials.py create mode 100644 homeassistant/components/google_health/config_flow.py create mode 100644 homeassistant/components/google_health/const.py create mode 100644 homeassistant/components/google_health/coordinator.py create mode 100644 homeassistant/components/google_health/icons.json create mode 100644 homeassistant/components/google_health/manifest.json create mode 100644 homeassistant/components/google_health/quality_scale.yaml create mode 100644 homeassistant/components/google_health/sensor.py create mode 100644 homeassistant/components/google_health/strings.json create mode 100644 tests/components/google_health/__init__.py create mode 100644 tests/components/google_health/conftest.py create mode 100644 tests/components/google_health/fixtures/distance.json create mode 100644 tests/components/google_health/fixtures/identity.json create mode 100644 tests/components/google_health/fixtures/resting_heart_rate.json create mode 100644 tests/components/google_health/fixtures/steps.json create mode 100644 tests/components/google_health/fixtures/userinfo.json create mode 100644 tests/components/google_health/fixtures/weight.json create mode 100644 tests/components/google_health/snapshots/test_sensor.ambr create mode 100644 tests/components/google_health/test_config_flow.py create mode 100644 tests/components/google_health/test_init.py create mode 100644 tests/components/google_health/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 5b5f29e7ed2f..735fa9e4361f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -242,6 +242,7 @@ homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* homeassistant.components.google_cloud.* homeassistant.components.google_drive.* +homeassistant.components.google_health.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.google_weather.* diff --git a/CODEOWNERS b/CODEOWNERS index 402b04d339c7..bc489bbe4f9b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -675,6 +675,8 @@ CLAUDE.md @home-assistant/core /tests/components/google_drive/ @tronikos /homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh /tests/components/google_generative_ai_conversation/ @tronikos @ivanlh +/homeassistant/components/google_health/ @allenporter +/tests/components/google_health/ @allenporter /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_photos/ @allenporter diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 117b7c6b63dd..4f3c62067ab4 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -8,6 +8,7 @@ "google_cloud", "google_drive", "google_generative_ai_conversation", + "google_health", "google_mail", "google_maps", "google_photos", diff --git a/homeassistant/components/google_health/__init__.py b/homeassistant/components/google_health/__init__.py new file mode 100644 index 000000000000..a389edc4bfa3 --- /dev/null +++ b/homeassistant/components/google_health/__init__.py @@ -0,0 +1,88 @@ +"""The Google Health integration.""" + +from dataclasses import dataclass + +from google_health_api import GoogleHealthApi +from google_health_api.const import HealthApiScope + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from . import api +from .const import DOMAIN +from .coordinator import GoogleHealthActivityCoordinator, GoogleHealthBodyCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +@dataclass +class GoogleHealthData: + """Class to hold Google Health coordinators.""" + + activity_coordinator: GoogleHealthActivityCoordinator | None = None + body_coordinator: GoogleHealthBodyCoordinator | None = None + + +type GoogleHealthConfigEntry = ConfigEntry[GoogleHealthData] + + +async def async_setup_entry( + hass: HomeAssistant, entry: GoogleHealthConfigEntry +) -> bool: + """Set up Google Health from a config entry.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth_error", + ) from err + + session = OAuth2Session(hass, entry, implementation) + + scopes = session.token.get("scope", "").split() + if HealthApiScope.PROFILE_READ not in scopes: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="missing_profile_scope", + ) + + auth = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + + api_client = GoogleHealthApi(auth) + + activity_coordinator = None + if all(scope in scopes for scope in api_client.steps.required_read_scopes): + activity_coordinator = GoogleHealthActivityCoordinator(hass, entry, api_client) + await activity_coordinator.async_config_entry_first_refresh() + + body_coordinator = None + if all(scope in scopes for scope in api_client.weight.required_read_scopes): + body_coordinator = GoogleHealthBodyCoordinator(hass, entry, api_client) + await body_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = GoogleHealthData( + activity_coordinator=activity_coordinator, + body_coordinator=body_coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleHealthConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/google_health/api.py b/homeassistant/components/google_health/api.py new file mode 100644 index 000000000000..02c3bcfd4b4a --- /dev/null +++ b/homeassistant/components/google_health/api.py @@ -0,0 +1,42 @@ +"""API for Google Health bound to Home Assistant OAuth.""" + +from typing import cast, override + +from aiohttp import ClientSession +from google_health_api.auth import AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Google Health authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Google Health auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + @override + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) + + +class SimpleAuth(AbstractAuth): + """Temporary auth helper for the config flow.""" + + def __init__(self, websession: ClientSession, access_token: str) -> None: + """Initialize the auth helper.""" + super().__init__(websession) + self._access_token = access_token + + @override + async def async_get_access_token(self) -> str: + """Return the access token.""" + return self._access_token diff --git a/homeassistant/components/google_health/application_credentials.py b/homeassistant/components/google_health/application_credentials.py new file mode 100644 index 000000000000..732a13fece13 --- /dev/null +++ b/homeassistant/components/google_health/application_credentials.py @@ -0,0 +1,23 @@ +"""Application credentials platform for the Google Health integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_health/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_health/config_flow.py b/homeassistant/components/google_health/config_flow.py new file mode 100644 index 000000000000..0eb1139b4434 --- /dev/null +++ b/homeassistant/components/google_health/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow for Google Health.""" + +import logging +from typing import Any, override + +from google_health_api import GoogleHealthApi +from google_health_api.const import HealthApiScope +from google_health_api.exceptions import GoogleHealthApiError + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from .api import SimpleAuth +from .const import DEFAULT_TITLE, DOMAIN, OAUTH_SCOPES + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Health OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + @override + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + @property + @override + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH_SCOPES), + "access_type": "offline", + "prompt": "consent", + } + + @override + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + scopes = data.get(CONF_TOKEN, {}).get("scope", "").split() + if HealthApiScope.PROFILE_READ not in scopes: + return self.async_abort(reason="missing_profile_scope") + + access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] + websession = aiohttp_client.async_get_clientsession(self.hass) + auth = SimpleAuth(websession, access_token) + api = GoogleHealthApi(auth) + + try: + identity = await api.get_identity() + except GoogleHealthApiError as err: + _LOGGER.error("Error getting Google Health identity: %s", err) + return self.async_abort(reason="cannot_connect") + + if not identity.health_user_id: + _LOGGER.error("Google Health identity has no health_user_id") + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(identity.health_user_id) + self._abort_if_unique_id_configured() + + display_name = None + if HealthApiScope.USERINFO_PROFILE in scopes or "profile" in scopes: + try: + userinfo = await api.get_user_info() + display_name = userinfo.given_name or userinfo.name + except Exception as err: # pylint: disable=broad-except # noqa: BLE001 + _LOGGER.warning("Error fetching user profile name: %s", err) + + return self.async_create_entry( + title=display_name or DEFAULT_TITLE, + data=data, + ) diff --git a/homeassistant/components/google_health/const.py b/homeassistant/components/google_health/const.py new file mode 100644 index 000000000000..03243729ae26 --- /dev/null +++ b/homeassistant/components/google_health/const.py @@ -0,0 +1,17 @@ +"""Constants for the Google Health integration.""" + +from google_health_api.const import HealthApiScope + +DOMAIN = "google_health" + +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" + +DEFAULT_TITLE = "Google Health" + +OAUTH_SCOPES = [ + HealthApiScope.ACTIVITY_READ, + HealthApiScope.PROFILE_READ, + HealthApiScope.MEASUREMENTS_READ, + HealthApiScope.USERINFO_PROFILE, +] diff --git a/homeassistant/components/google_health/coordinator.py b/homeassistant/components/google_health/coordinator.py new file mode 100644 index 000000000000..680bccb0e1b6 --- /dev/null +++ b/homeassistant/components/google_health/coordinator.py @@ -0,0 +1,176 @@ +"""Coordinators for Google Health.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, override + +from google_health_api import GoogleHealthApi +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, + HealthAuthException, +) +from google_health_api.model import ( + DailyRestingHeartRate, + DistanceRollupValue, + StepsRollupValue, + Weight, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import GoogleHealthConfigEntry + +_LOGGER = logging.getLogger(__name__) + +POLLING_INTERVAL = timedelta(minutes=15) +BODY_POLLING_INTERVAL = timedelta(hours=1) +DEFAULT_PAGE_SIZE = 1 + + +@dataclass +class GoogleHealthActivityData: + """Class to hold activity data.""" + + steps: StepsRollupValue | None = None + distance: DistanceRollupValue | None = None + + +@dataclass +class GoogleHealthBodyData: + """Class to hold body measurements.""" + + weight: Weight | None = None + resting_heart_rate: DailyRestingHeartRate | None = None + + +class GoogleHealthDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for Google Health API.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + update_interval: timedelta, + entry: GoogleHealthConfigEntry, + api_client: GoogleHealthApi, + ) -> None: + """Initialize the coordinator.""" + self.api = api_client + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + config_entry=entry, + ) + + @override + async def _async_update_data(self) -> _DataT: + """Fetch data from API.""" + try: + return await self._async_fetch_data() + except (HealthAuthException, HealthApiForbiddenException) as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + except GoogleHealthApiError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + + async def _async_fetch_data(self) -> _DataT: + """Fetch data from API.""" + raise NotImplementedError + + +class GoogleHealthActivityCoordinator( + GoogleHealthDataUpdateCoordinator[GoogleHealthActivityData] +): + """Coordinator to fetch activity data from Google Health API.""" + + def __init__( + self, + hass: HomeAssistant, + entry: GoogleHealthConfigEntry, + api_client: GoogleHealthApi, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_activity", + update_interval=POLLING_INTERVAL, + entry=entry, + api_client=api_client, + ) + + @override + async def _async_fetch_data(self) -> GoogleHealthActivityData: + """Fetch steps and distance rollup for today. + + Queries the daily rollup endpoints using Home Assistant's local time zone + to aggregate step and distance counts over the current civil day. If no + data points exist for today yet, the API returns None, which the sensors + default to 0. + """ + steps_rollup = await self.api.steps.today(self.hass.config.time_zone) + distance_rollup = await self.api.distance.today(self.hass.config.time_zone) + + steps = steps_rollup.data if steps_rollup else None + distance = distance_rollup.data if distance_rollup else None + + return GoogleHealthActivityData(steps=steps, distance=distance) + + +class GoogleHealthBodyCoordinator( + GoogleHealthDataUpdateCoordinator[GoogleHealthBodyData] +): + """Coordinator to fetch body measurements from Google Health API.""" + + def __init__( + self, + hass: HomeAssistant, + entry: GoogleHealthConfigEntry, + api_client: GoogleHealthApi, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_body", + update_interval=BODY_POLLING_INTERVAL, + entry=entry, + api_client=api_client, + ) + + @override + async def _async_fetch_data(self) -> GoogleHealthBodyData: + """Fetch latest body weight and resting heart rate.""" + # The Google Health API returns data points sorted by interval start time + # in descending order (newest first). Querying with page_size=1 and grabbing + # the first element is sufficient to fetch the most recent measurement. + weight_result = await self.api.weight.list(page_size=DEFAULT_PAGE_SIZE) + hr_result = await self.api.daily_resting_heart_rate.list( + page_size=DEFAULT_PAGE_SIZE + ) + + weight = ( + weight_result.data_points[0].data if weight_result.data_points else None + ) + resting_heart_rate = ( + hr_result.data_points[0].data if hr_result.data_points else None + ) + + return GoogleHealthBodyData( + weight=weight, resting_heart_rate=resting_heart_rate + ) diff --git a/homeassistant/components/google_health/icons.json b/homeassistant/components/google_health/icons.json new file mode 100644 index 000000000000..2c15524eb0f2 --- /dev/null +++ b/homeassistant/components/google_health/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "resting_heart_rate": { + "default": "mdi:heart-pulse" + }, + "steps": { + "default": "mdi:walk" + } + } + } +} diff --git a/homeassistant/components/google_health/manifest.json b/homeassistant/components/google_health/manifest.json new file mode 100644 index 000000000000..52ce63008c72 --- /dev/null +++ b/homeassistant/components/google_health/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_health", + "name": "Google Health", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["application_credentials", "http", "webhook"], + "documentation": "https://www.home-assistant.io/integrations/google_health", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["google-health-api==0.5.1"] +} diff --git a/homeassistant/components/google_health/quality_scale.yaml b/homeassistant/components/google_health/quality_scale.yaml new file mode 100644 index 000000000000..95d9da564f3c --- /dev/null +++ b/homeassistant/components/google_health/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have custom triggers. + entity-event-setup: + status: exempt + comment: This integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters are available. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration does not support discovery. + discovery: + status: exempt + comment: This integration does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: + status: exempt + comment: All entities are user-facing primary sensors. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: No entities need to be disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not raise repair issues. + stale-devices: + status: exempt + comment: This integration has a static device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/google_health/sensor.py b/homeassistant/components/google_health/sensor.py new file mode 100644 index 000000000000..f841058115d4 --- /dev/null +++ b/homeassistant/components/google_health/sensor.py @@ -0,0 +1,138 @@ +"""Sensor platform for the Google Health integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast, override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfMass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GoogleHealthConfigEntry +from .const import DOMAIN +from .coordinator import ( + GoogleHealthActivityCoordinator, + GoogleHealthBodyCoordinator, + GoogleHealthDataUpdateCoordinator, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class GoogleHealthSensorEntityDescription[ + _CoordinatorT: GoogleHealthDataUpdateCoordinator[Any], + _ValueT: StateType, +](SensorEntityDescription): + """Class describing Google Health sensor entities.""" + + value_fn: Callable[[Any], _ValueT] + + +ACTIVITY_SENSORS: list[ + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, Any] +] = [ + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int]( + key="steps", + translation_key="steps", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.steps.count_sum if data and data.steps else 0, + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0 + ), + ), +] + +BODY_SENSORS: list[ + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, Any] +] = [ + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None]( + key="weight", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.weight.weight_grams / 1000.0 if data and data.weight else None + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, int | None]( + key="resting_heart_rate", + translation_key="resting_heart_rate", + native_unit_of_measurement="bpm", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.resting_heart_rate.beats_per_minute + if data and data.resting_heart_rate + else None + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GoogleHealthConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Google Health sensor platform.""" + data = entry.runtime_data + + entities: list[SensorEntity] = [] + if (activity_coordinator := data.activity_coordinator) is not None: + entities.extend( + GoogleHealthSensor(activity_coordinator, entry.entry_id, description) + for description in ACTIVITY_SENSORS + ) + if (body_coordinator := data.body_coordinator) is not None: + entities.extend( + GoogleHealthSensor(body_coordinator, entry.entry_id, description) + for description in BODY_SENSORS + ) + + if entities: + async_add_entities(entities) + + +class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]]( + CoordinatorEntity[_CoordinatorT], SensorEntity +): + """Generic Google Health sensor entity.""" + + _attr_has_entity_name = True + entity_description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any] + + def __init__( + self, + coordinator: _CoordinatorT, + entry_id: str, + description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any], + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + manufacturer="Google", + ) + + @property + @override + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return cast(StateType, self.entity_description.value_fn(self.coordinator.data)) diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json new file mode 100644 index 000000000000..08c5257344a8 --- /dev/null +++ b/homeassistant/components/google_health/strings.json @@ -0,0 +1,55 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Health data. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + }, + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "cannot_connect": "Failed to connect.", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_profile_scope": "Missing required Google Health profile read permission.", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + } + }, + "entity": { + "sensor": { + "resting_heart_rate": { + "name": "Resting heart rate" + }, + "steps": { + "name": "Steps", + "unit_of_measurement": "steps" + } + } + }, + "exceptions": { + "auth_error": { + "message": "Authentication or permission error talking to Google Health." + }, + "communication_error": { + "message": "Error communicating with Google Health." + }, + "missing_profile_scope": { + "message": "Missing required Google Health profile read permission." + }, + "oauth_error": { + "message": "OAuth2 implementation temporarily unavailable." + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 06ebd9b263f9..06fe7da634be 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [ "google", "google_assistant_sdk", "google_drive", + "google_health", "google_mail", "google_photos", "google_sheets", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4d0256cc4f05..e17d12b77ef1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -284,6 +284,7 @@ FLOWS = { "google_cloud", "google_drive", "google_generative_ai_conversation", + "google_health", "google_mail", "google_photos", "google_sheets", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a9be4c9cf706..0fb73355adb8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2554,6 +2554,12 @@ "iot_class": "cloud_polling", "name": "Google Gemini" }, + "google_health": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Health" + }, "google_mail": { "integration_type": "service", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index a8fea92f7875..1fbb8b1a495c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2177,6 +2177,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_health.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_photos.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3dfcf2642cfd..cf70a636c45b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,6 +1133,9 @@ google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.59.0 +# homeassistant.components.google_health +google-health-api==0.5.1 + # homeassistant.components.google_travel_time google-maps-routing==0.6.15 diff --git a/tests/components/google_health/__init__.py b/tests/components/google_health/__init__.py new file mode 100644 index 000000000000..8ab7166c02a0 --- /dev/null +++ b/tests/components/google_health/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Health integration.""" diff --git a/tests/components/google_health/conftest.py b/tests/components/google_health/conftest.py new file mode 100644 index 000000000000..275936e3929e --- /dev/null +++ b/tests/components/google_health/conftest.py @@ -0,0 +1,175 @@ +"""Test fixtures for Google Health.""" + +from collections.abc import Awaitable, Callable, Generator +import time +from typing import Any +from unittest.mock import AsyncMock, patch + +from google_health_api.model import ( + DAILY_RESTING_HEART_RATE, + WEIGHT, + DailyRollupDataPoint, + DataPoint, + DataType, + DistanceRollupValue, + Identity, + ListDataPointResult, + StepsRollupValue, + UserInfo, + _ListDataPointsModel, +) +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_health.const import DOMAIN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_json_object_fixture + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + + +def _rollup_fixture( + filename: str, rollup_cls: type, field_name: str +) -> DailyRollupDataPoint | None: + """Build the most recent daily rollup data point from a fixture.""" + points = load_json_object_fixture(filename, DOMAIN)["rollupDataPoints"] + if not points: + return None + return DailyRollupDataPoint.from_api_dict(rollup_cls, field_name, points[0]) + + +def _list_fixture(filename: str, data_type: DataType) -> ListDataPointResult: + """Build a list data point result from a fixture.""" + data_points = [ + DataPoint.from_api_dict(data_type, item) + for item in load_json_object_fixture(filename, DOMAIN)["dataPoints"] + ] + return ListDataPointResult(_ListDataPointsModel(data_points=data_points)) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return int(time.time() + 86400) + + +@pytest.fixture +def scopes() -> list[str]: + """Fixture with scopes to set up.""" + return OAUTH_SCOPES + + +@pytest.fixture(name="token_entry") +def mock_token_entry(expires_at: int, scopes: list[str]) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(scopes), + "token_type": "Bearer", + "expires_at": expires_at, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Google Health", + unique_id="mock-health-user-id", + entry_id="01J0BC4QM2YBRP6H5G933CETT7", + data={ + "auth_implementation": DOMAIN, + "token": token_entry, + }, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.google_health.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_google_health_client() -> Generator[AsyncMock]: + """Mock a Google Health client.""" + with ( + patch( + "homeassistant.components.google_health.GoogleHealthApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.google_health.config_flow.GoogleHealthApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.steps = AsyncMock() + client.steps.today.return_value = _rollup_fixture( + "steps.json", StepsRollupValue, "steps" + ) + client.steps.required_read_scopes = [ + "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly" + ] + client.distance = AsyncMock() + client.distance.today.return_value = _rollup_fixture( + "distance.json", DistanceRollupValue, "distance" + ) + client.weight = AsyncMock() + client.weight.list.return_value = _list_fixture("weight.json", WEIGHT) + client.weight.required_read_scopes = [ + "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly" + ] + client.daily_resting_heart_rate = AsyncMock() + client.daily_resting_heart_rate.list.return_value = _list_fixture( + "resting_heart_rate.json", DAILY_RESTING_HEART_RATE + ) + client.get_identity.return_value = Identity.from_dict( + load_json_object_fixture("identity.json", DOMAIN) + ) + client.get_user_info.return_value = UserInfo.from_dict( + load_json_object_fixture("userinfo.json", DOMAIN) + ) + yield client + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_credentials: None, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run diff --git a/tests/components/google_health/fixtures/distance.json b/tests/components/google_health/fixtures/distance.json new file mode 100644 index 000000000000..b8f1d4b3b163 --- /dev/null +++ b/tests/components/google_health/fixtures/distance.json @@ -0,0 +1,11 @@ +{ + "rollupDataPoints": [ + { + "distance": { + "millimetersSum": 5000000 + }, + "civilStartTime": { "date": { "year": 2026, "month": 6, "day": 28 } }, + "civilEndTime": { "date": { "year": 2026, "month": 6, "day": 29 } } + } + ] +} diff --git a/tests/components/google_health/fixtures/identity.json b/tests/components/google_health/fixtures/identity.json new file mode 100644 index 000000000000..f4de595d72ef --- /dev/null +++ b/tests/components/google_health/fixtures/identity.json @@ -0,0 +1,5 @@ +{ + "name": "users/me/identity", + "healthUserId": "mock-health-user-id", + "legacyUserId": "mock-legacy-user-id" +} diff --git a/tests/components/google_health/fixtures/resting_heart_rate.json b/tests/components/google_health/fixtures/resting_heart_rate.json new file mode 100644 index 000000000000..37f7345f4bed --- /dev/null +++ b/tests/components/google_health/fixtures/resting_heart_rate.json @@ -0,0 +1,10 @@ +{ + "dataPoints": [ + { + "dailyRestingHeartRate": { + "beatsPerMinute": 65, + "date": { "year": 2026, "month": 6, "day": 29 } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/steps.json b/tests/components/google_health/fixtures/steps.json new file mode 100644 index 000000000000..9a0a1e44ee2e --- /dev/null +++ b/tests/components/google_health/fixtures/steps.json @@ -0,0 +1,11 @@ +{ + "rollupDataPoints": [ + { + "steps": { + "countSum": 10500 + }, + "civilStartTime": { "date": { "year": 2026, "month": 6, "day": 28 } }, + "civilEndTime": { "date": { "year": 2026, "month": 6, "day": 29 } } + } + ] +} diff --git a/tests/components/google_health/fixtures/userinfo.json b/tests/components/google_health/fixtures/userinfo.json new file mode 100644 index 000000000000..20eab08c05de --- /dev/null +++ b/tests/components/google_health/fixtures/userinfo.json @@ -0,0 +1,5 @@ +{ + "sub": "mock-sub", + "given_name": "Allen", + "name": "Allen Porter" +} diff --git a/tests/components/google_health/fixtures/weight.json b/tests/components/google_health/fixtures/weight.json new file mode 100644 index 000000000000..a651ae872b04 --- /dev/null +++ b/tests/components/google_health/fixtures/weight.json @@ -0,0 +1,12 @@ +{ + "dataPoints": [ + { + "weight": { + "weightGrams": 80000.0, + "sampleTime": { + "physicalTime": "2026-06-29T00:00:00Z" + } + } + } + ] +} diff --git a/tests/components/google_health/snapshots/test_sensor.ambr b/tests/components/google_health/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..b1973b10e0ae --- /dev/null +++ b/tests/components/google_health/snapshots/test_sensor.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_all_entities[sensor.google_health_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Distance', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'distance', + : 'Google Health Distance', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.0', + }) +# --- +# name: test_all_entities[sensor.google_health_resting_heart_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_resting_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Resting heart rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resting heart rate', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resting_heart_rate', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_resting_heart_rate', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.google_health_resting_heart_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Resting heart rate', + : , + : 'bpm', + }), + 'context': , + 'entity_id': 'sensor.google_health_resting_heart_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_all_entities[sensor.google_health_steps-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_steps', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Steps', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steps', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'steps', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_steps', + 'unit_of_measurement': 'steps', + }) +# --- +# name: test_all_entities[sensor.google_health_steps-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Steps', + : , + : 'steps', + }), + 'context': , + 'entity_id': 'sensor.google_health_steps', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10500', + }) +# --- +# name: test_all_entities[sensor.google_health_weight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Weight', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weight', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_weight', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_weight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'weight', + : 'Google Health Weight', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_weight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py new file mode 100644 index 000000000000..3410f2f3a269 --- /dev/null +++ b/tests/components/google_health/test_config_flow.py @@ -0,0 +1,256 @@ +"""Test the Google Health config flow.""" + +from unittest.mock import AsyncMock + +from google_health_api.exceptions import GoogleHealthApiError +from google_health_api.model import Identity +import pytest + +from homeassistant.components.google_health.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + OAUTH_SCOPES, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.mark.usefixtures( + "current_request_with_host", + "mock_setup_entry", + "setup_credentials", + "mock_google_health_client", +) +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly" + "+https://www.googleapis.com/auth/googlehealth.profile.readonly" + "+https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline" + "&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Allen" + assert result["result"].unique_id == "mock-health-user-id" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_missing_profile_scope( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test config flow aborts if profile read scope is missing from token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + # Return a token containing only the activity scope (missing profile scope) + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_profile_scope" + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_get_identity_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if get_identity raises an API error.""" + mock_google_health_client.get_identity.side_effect = GoogleHealthApiError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_missing_health_user_id( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if identity does not contain healthUserId.""" + mock_google_health_client.get_identity.return_value = Identity( + name="users/me/identity", health_user_id="" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_profile_name_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test flow completes with default title if fetching profile userinfo fails.""" + mock_google_health_client.get_user_info.side_effect = GoogleHealthApiError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Health" diff --git a/tests/components/google_health/test_init.py b/tests/components/google_health/test_init.py new file mode 100644 index 000000000000..ec80e866cf83 --- /dev/null +++ b/tests/components/google_health/test_init.py @@ -0,0 +1,153 @@ +"""Tests for Google Health integration lifecycle (init/unloading).""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, patch + +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, +) +import pytest + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_google_health_client") +async def test_setup_and_unload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test standard setup and unloading of the config entry.""" + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + assert hass.states.get("sensor.google_health_steps") is not None + assert hass.states.get("sensor.google_health_distance") is not None + assert hass.states.get("sensor.google_health_weight") is not None + assert hass.states.get("sensor.google_health_resting_heart_rate") is not None + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_setup_api_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_google_health_client: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup error retry handling when API fails.""" + mock_google_health_client.steps.today.side_effect = GoogleHealthApiError + + assert not await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_google_health_client: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup error when API returns auth or forbidden errors.""" + mock_google_health_client.steps.today.side_effect = HealthApiForbiddenException + + assert not await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +@pytest.mark.usefixtures("mock_google_health_client") +@pytest.mark.parametrize( + "scopes", ["https://www.googleapis.com/auth/health.activity_and_fitness.readonly"] +) +async def test_setup_missing_scopes( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup fails if token has missing profile scope.""" + assert not await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +@pytest.mark.usefixtures("mock_google_health_client") +@pytest.mark.parametrize( + "scopes", + [ + [ + "https://www.googleapis.com/auth/googlehealth.profile.readonly", + "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", + ] + ], +) +async def test_setup_missing_activity_scope( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup succeeds but steps sensor is not added if activity scope is missing.""" + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + assert hass.states.get("sensor.google_health_steps") is None + assert hass.states.get("sensor.google_health_distance") is None + + assert hass.states.get("sensor.google_health_weight") is not None + assert hass.states.get("sensor.google_health_resting_heart_rate") is not None + + +@pytest.mark.usefixtures("mock_google_health_client") +@pytest.mark.parametrize( + "scopes", + [ + [ + "https://www.googleapis.com/auth/googlehealth.profile.readonly", + "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", + ] + ], +) +async def test_setup_missing_measurements_scope( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup succeeds but weight sensor is not added if measurements scope is missing.""" + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + assert hass.states.get("sensor.google_health_weight") is None + assert hass.states.get("sensor.google_health_resting_heart_rate") is None + + assert hass.states.get("sensor.google_health_steps") is not None + assert hass.states.get("sensor.google_health_distance") is not None + + +async def test_setup_oauth_implementation_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_health.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/google_health/test_sensor.py b/tests/components/google_health/test_sensor.py new file mode 100644 index 000000000000..9e3b9b0f9600 --- /dev/null +++ b/tests/components/google_health/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for Google Health sensor platform.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_google_health_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test all sensor entities.""" + with patch("homeassistant.components.google_health._PLATFORMS", [Platform.SENSOR]): + assert await integration_setup() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_sensor_empty_rollup( + hass: HomeAssistant, + mock_google_health_client: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test steps and distance sensors when the rollup endpoint returns no data.""" + mock_google_health_client.steps.today.return_value = None + mock_google_health_client.distance.today.return_value = None + + assert await integration_setup() + + steps_state = hass.states.get("sensor.google_health_steps") + assert steps_state is not None + assert steps_state.state == "0" + + distance_state = hass.states.get("sensor.google_health_distance") + assert distance_state is not None + assert distance_state.state == "0.0" From 44775e9646135f881afefe9ec1005ab0b8a3bf66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:05:30 +0200 Subject: [PATCH 194/707] Use state attribute enums in filter (#175872) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/filter/sensor.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 6ed1a61eaab3..f96884724983 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -16,23 +16,21 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import get_instance, history from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, + SensorEntityCapabilityAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -307,17 +305,21 @@ class SensorFilter(SensorEntity): self._state = temp_state.state - self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) - self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) + self._attr_icon = new_state.attributes.get(EntityStateAttribute.ICON, ICON) + self._attr_device_class = new_state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) + self._attr_state_class = new_state.attributes.get( + SensorEntityCapabilityAttribute.STATE_CLASS + ) if self._attr_native_unit_of_measurement != new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ): for filt in self._filters: filt.reset() self._attr_native_unit_of_measurement = new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) if update_ha: From 4685a4a7a3712412f94486a8aab86e0b4cc2902a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Jul 2026 21:11:16 +0200 Subject: [PATCH 195/707] Add pylint rule enforcing usb dependency for SerialPortSelector (#175876) Co-authored-by: Claude --- homeassistant/components/edl21/manifest.json | 1 + .../modbus_connection/manifest.json | 1 + .../components/russound_rio/manifest.json | 2 +- homeassistant/components/upb/manifest.json | 1 + homeassistant/components/zha/manifest.json | 4 +- pylint/plugins/README.md | 15 +++ .../config_flow/serial_port_usb_dependency.py | 81 +++++++++++++ .../test_serial_port_usb_dependency.py | 112 ++++++++++++++++++ tests/pylint/conftest.py | 14 +++ 9 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py create mode 100644 tests/pylint/config_flow/test_serial_port_usb_dependency.py diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 4970b73838cc..d5e675fc7760 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -3,6 +3,7 @@ "name": "EDL21", "codeowners": [], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/edl21", "integration_type": "device", "iot_class": "local_push", diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json index 4bac19ead28c..79cfe652c1ce 100644 --- a/homeassistant/components/modbus_connection/manifest.json +++ b/homeassistant/components/modbus_connection/manifest.json @@ -3,6 +3,7 @@ "name": "Modbus Connection", "codeowners": ["@home-assistant/core"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/modbus_connection", "integration_type": "hub", "iot_class": "local_polling", diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 64cf366ca6e9..1b6ea774f699 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -1,9 +1,9 @@ { "domain": "russound_rio", "name": "Russound RIO", - "after_dependencies": ["usb"], "codeowners": ["@noahhusby"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/russound_rio", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 8eb8e99fd355..dc44e595d988 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -3,6 +3,7 @@ "name": "Universal Powerline Bus (UPB)", "codeowners": ["@gwww"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c6db84d60e77..d6d6fa08516f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -1,10 +1,10 @@ { "domain": "zha", "name": "Zigbee Home Automation", - "after_dependencies": ["hassio", "onboarding", "usb"], + "after_dependencies": ["hassio", "onboarding"], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "config_flow": true, - "dependencies": ["file_upload", "homeassistant_hardware"], + "dependencies": ["file_upload", "homeassistant_hardware", "usb"], "documentation": "https://www.home-assistant.io/integrations/zha", "integration_type": "hub", "iot_class": "local_polling", diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index 11af4d2b5af0..01d6af257e39 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -132,6 +132,7 @@ Every check has a code following the | `W7415` | [`home-assistant-sequential-executor-jobs`](#w7415-home-assistant-sequential-executor-jobs) | Sequential `async_add_executor_job` calls should be grouped | | `W7416` | [`home-assistant-missing-has-entity-name`](#w7416-home-assistant-missing-has-entity-name) | Entity class should set `_attr_has_entity_name = True` | | `W7429` | [`home-assistant-unnecessary-format-mac`](#w7429-home-assistant-unnecessary-format-mac) | `format_mac()` is unnecessary with `CONNECTION_NETWORK_MAC` | +| `W7430` | [`home-assistant-serial-port-selector-usb-dependency`](#w7430-home-assistant-serial-port-selector-usb-dependency) | Config flow using `SerialPortSelector` must declare `usb` in `dependencies` | ## `home_assistant_logger` checker @@ -849,3 +850,17 @@ Tuples used for direct comparison against `device.connections` (e.g. the `in` operator, set intersection) are not flagged because those comparisons bypass the device registry normalization and genuinely need `format_mac()` to match the stored normalized format. + + +## `home_assistant_serial_port_selector_usb_dependency` checker + +Detects config flows using `SerialPortSelector` whose `manifest.json` does +not declare `usb` as a hard dependency. + +### `W7430`: `home-assistant-serial-port-selector-usb-dependency` + +`SerialPortSelector` populates its port list via the `usb/list_serial_ports` +websocket command, which is only registered when the `usb` integration is set +up. The selector therefore requires `usb` as a hard dependency +(`"dependencies": ["usb"]`); `after_dependencies` is not sufficient because it +does not force `usb` to be set up. diff --git a/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py b/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py new file mode 100644 index 000000000000..cbdf75cde488 --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py @@ -0,0 +1,81 @@ +"""Checker for the usb dependency when a config flow uses SerialPortSelector. + +``SerialPortSelector`` populates its port list via the +``usb/list_serial_ports`` websocket command, which is only registered when the +``usb`` integration is set up. Integrations using the selector must therefore +declare ``usb`` as a hard dependency in ``manifest.json`` so it is guaranteed +to be set up; ``after_dependencies`` is not sufficient because it does not +force ``usb`` to be set up. +""" + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +from pylint_home_assistant.const import Module +from pylint_home_assistant.helpers.integration import read_manifest +from pylint_home_assistant.helpers.module_info import parse_module + + +class HassEnforceSerialPortSelectorUsbChecker(BaseChecker): + """Checker for the usb dependency when using SerialPortSelector.""" + + name = "home_assistant_serial_port_selector_usb_dependency" + priority = -1 + msgs = { + "W7430": ( + "Config flow uses SerialPortSelector but the integration does not " + "declare 'usb' in 'dependencies' in manifest.json", + "home-assistant-serial-port-selector-usb-dependency", + "SerialPortSelector populates its port list via the " + "'usb/list_serial_ports' websocket command, which is only " + "registered when the 'usb' integration is set up. The selector " + "therefore requires 'usb' as a hard dependency; " + "'after_dependencies' is not sufficient because it does not force " + "'usb' to be set up.", + ), + } + options = () + + def __init__(self, linter: PyLinter) -> None: + """Initialize the checker.""" + super().__init__(linter) + self._reported_modules: set[str] = set() + + def visit_call(self, node: nodes.Call) -> None: + """Check that SerialPortSelector usage declares the usb dependency.""" + func = node.func + if isinstance(func, nodes.Attribute): + name = func.attrname + elif isinstance(func, nodes.Name): + name = func.name + else: + return + if name != "SerialPortSelector": + return + + parsed = parse_module(node.root().name) + if parsed is None or parsed.module != Module.CONFIG_FLOW: + return + + module_name = node.root().name + if module_name in self._reported_modules: + return + + manifest = read_manifest(node.root()) + if manifest is None: + return + + if "usb" in manifest.get("dependencies", []): + return + + self._reported_modules.add(module_name) + self.add_message( + "home-assistant-serial-port-selector-usb-dependency", + node=node, + ) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSerialPortSelectorUsbChecker(linter)) diff --git a/tests/pylint/config_flow/test_serial_port_usb_dependency.py b/tests/pylint/config_flow/test_serial_port_usb_dependency.py new file mode 100644 index 000000000000..8d637cca07b7 --- /dev/null +++ b/tests/pylint/config_flow/test_serial_port_usb_dependency.py @@ -0,0 +1,112 @@ +"""Tests for the serial_port_selector usb dependency pylint plugin.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import astroid +from pylint.checkers import BaseChecker +from pylint.testutils.unittest_linter import UnittestLinter +import pytest + +from tests.pylint import assert_no_messages, walk_checker + + +def _write_integration(tmp_path: Path, domain: str, manifest: dict) -> Path: + """Create an integration directory with a manifest and config_flow.""" + integration_dir = tmp_path / "homeassistant" / "components" / domain + integration_dir.mkdir(parents=True) + (integration_dir / "config_flow.py").touch() + (integration_dir / "manifest.json").write_text(json.dumps(manifest)) + return integration_dir + + +def test_serial_port_selector_with_usb_dependency( + linter: UnittestLinter, + enforce_serial_port_selector_usb_checker: BaseChecker, + tmp_path: Path, +) -> None: + """A config flow declaring usb as a hard dependency is not flagged.""" + integration_dir = _write_integration( + tmp_path, "my_device", {"domain": "my_device", "dependencies": ["usb"]} + ) + + code = """ + vol.Schema({vol.Required(CONF_PORT): SerialPortSelector()}) + """ + root_node = astroid.parse(code, "homeassistant.components.my_device.config_flow") + root_node.file = str(integration_dir / "config_flow.py") + + with assert_no_messages(linter): + walk_checker(linter, enforce_serial_port_selector_usb_checker, root_node) + + +@pytest.mark.parametrize( + ("code", "module_name"), + [ + pytest.param( + """ + vol.Schema({vol.Required(CONF_HOST): TextSelector()}) + """, + "homeassistant.components.my_device.config_flow", + id="other_selector", + ), + pytest.param( + """ + SerialPortSelector() + """, + "homeassistant.components.my_device.sensor", + id="not_config_flow", + ), + ], +) +def test_serial_port_selector_not_flagged( + linter: UnittestLinter, + enforce_serial_port_selector_usb_checker: BaseChecker, + tmp_path: Path, + code: str, + module_name: str, +) -> None: + """Cases that must not be flagged even without a usb dependency.""" + integration_dir = _write_integration(tmp_path, "my_device", {"domain": "my_device"}) + + root_node = astroid.parse(code, module_name) + root_node.file = str(integration_dir / f"{module_name.rsplit('.', 1)[1]}.py") + + with assert_no_messages(linter): + walk_checker(linter, enforce_serial_port_selector_usb_checker, root_node) + + +@pytest.mark.parametrize( + "manifest", + [ + pytest.param({"domain": "my_device"}, id="no_dependency"), + pytest.param( + {"domain": "my_device", "after_dependencies": ["usb"]}, + id="after_dependencies_only", + ), + ], +) +def test_serial_port_selector_without_usb_dependency( + linter: UnittestLinter, + enforce_serial_port_selector_usb_checker: BaseChecker, + tmp_path: Path, + manifest: dict, +) -> None: + """A config flow without usb as a hard dependency is flagged once.""" + integration_dir = _write_integration(tmp_path, "my_device", manifest) + + code = """ + vol.Schema({ + vol.Required(CONF_PORT): SerialPortSelector(), + vol.Optional(CONF_OTHER): SerialPortSelector(), + }) + """ + root_node = astroid.parse(code, "homeassistant.components.my_device.config_flow") + root_node.file = str(integration_dir / "config_flow.py") + + walk_checker(linter, enforce_serial_port_selector_usb_checker, root_node) + messages = linter.release_messages() + assert len(messages) == 1 + assert messages[0].msg_id == "home-assistant-serial-port-selector-usb-dependency" diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 8ac3720f2eb2..4b60bfbb8d73 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -9,6 +9,9 @@ from pylint_home_assistant.checkers.config_flow.no_name import ( from pylint_home_assistant.checkers.config_flow.no_polling import ( HassEnforceConfigFlowNoPollingChecker, ) +from pylint_home_assistant.checkers.config_flow.serial_port_usb_dependency import ( + HassEnforceSerialPortSelectorUsbChecker, +) from pylint_home_assistant.checkers.config_flow.unique_id_no_ip import ( HassEnforceConfigEntryUniqueIdNoIpChecker, ) @@ -95,6 +98,17 @@ def enforce_config_entry_unique_id_no_ip_checker_fixture( return checker +@pytest.fixture(name="enforce_serial_port_selector_usb_checker") +def enforce_serial_port_selector_usb_checker_fixture( + linter: UnittestLinter, +) -> BaseChecker: + """Fixture to provide a serial_port_selector usb dependency checker.""" + clear_caches() + checker = HassEnforceSerialPortSelectorUsbChecker(linter) + checker.module = "homeassistant.components.pylint_test" + return checker + + @pytest.fixture(name="enforce_config_flow_no_name_checker") def enforce_config_flow_no_name_checker_fixture( linter: UnittestLinter, From 9fb75f2e38a21131f1eb84893b7edb9d9c7b06c6 Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Tue, 7 Jul 2026 23:00:38 +0200 Subject: [PATCH 196/707] Add reconfiguration support for stiebel_eltron integration (#175886) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../components/stiebel_eltron/config_flow.py | 56 +++++++- .../stiebel_eltron/quality_scale.yaml | 2 +- .../components/stiebel_eltron/strings.json | 11 ++ .../stiebel_eltron/test_config_flow.py | 131 ++++++++++++++---- 4 files changed, 165 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py index ff3761e62e7f..89b58dc9f1af 100644 --- a/homeassistant/components/stiebel_eltron/config_flow.py +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -8,11 +8,29 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) from .const import DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=65535, mode=NumberSelectorMode.BOX) + ), + vol.Coerce(int), + ), + } +) + async def check_controller_model(host: str, port: int) -> str | None: """Check if the controller model is valid.""" @@ -52,11 +70,39 @@ class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): int, - } + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow.""" + config_entry = self._get_reconfigure_entry() + + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + error = await check_controller_model( + user_input[CONF_HOST], user_input[CONF_PORT] + ) + if error is not None: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + config_entry, + data_updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, config_entry.data ), errors=errors, ) diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml index 24a0036d9fac..fb718177cb62 100644 --- a/homeassistant/components/stiebel_eltron/quality_scale.yaml +++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml @@ -64,7 +64,7 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/stiebel_eltron/strings.json b/homeassistant/components/stiebel_eltron/strings.json index aa40b754a6d6..9156f5f7f667 100644 --- a/homeassistant/components/stiebel_eltron/strings.json +++ b/homeassistant/components/stiebel_eltron/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { @@ -10,6 +11,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::stiebel_eltron::config::step::user::data_description::host%]", + "port": "[%key:component::stiebel_eltron::config::step::user::data_description::port%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py index 377091d30df5..6747a312d612 100644 --- a/tests/components/stiebel_eltron/test_config_flow.py +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -3,15 +3,19 @@ from unittest.mock import MagicMock from pystiebeleltron import ControllerModel, StiebelEltronModbusError +import pytest from homeassistant.components.stiebel_eltron.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +USER_INPUT = {CONF_HOST: "1.1.1.1", CONF_PORT: 502} +RECONFIGURE_INPUT = {CONF_HOST: "2.2.2.2", CONF_PORT: 502} + async def test_full_flow(hass: HomeAssistant) -> None: """Test the full flow.""" @@ -23,18 +27,12 @@ async def test_full_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Stiebel Eltron" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - } + assert result["data"] == USER_INPUT async def test_form_cannot_connect( @@ -50,10 +48,7 @@ async def test_form_cannot_connect( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.FORM @@ -63,10 +58,7 @@ async def test_form_cannot_connect( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -85,10 +77,7 @@ async def test_form_unknown_exception( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.FORM @@ -99,15 +88,102 @@ async def test_form_unknown_exception( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "2.2.2.2" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param(StiebelEltronModbusError, "cannot_connect", id="cannot_connect"), + pytest.param(Exception, "unknown", id="unknown"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_controller_model: MagicMock, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test error handling in reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + assert result["type"] is FlowResultType.FORM + + mock_get_controller_model.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_get_controller_model.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure aborts if another entry already uses the given host/port.""" + other_entry = MockConfigEntry( + domain=DOMAIN, + title="Stiebel Eltron", + data=RECONFIGURE_INPUT, + entry_id="stiebel_eltron_002", + ) + + mock_config_entry.add_to_hass(hass) + other_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -119,10 +195,7 @@ async def test_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.ABORT From f46311d3594f3ec9d397adda1b32ae05cd1991de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jul 2026 16:03:13 -0500 Subject: [PATCH 197/707] Fix reversed args in esphome assist_in_progress cleanup (#175885) --- homeassistant/components/esphome/manager.py | 2 +- tests/components/esphome/test_manager.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 3eeaed77be5e..2a3c44357d6d 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -917,8 +917,8 @@ class ESPHomeManager: # Remove this after 2026.4 if not ( stale_entry_entity_id := ent_reg.async_get_entity_id( - DOMAIN, Platform.BINARY_SENSOR, + DOMAIN, f"{self.entry_data.device_info.mac_address}-assist_in_progress", ) ): diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 96ffcb52d3b7..343601ffe203 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1933,8 +1933,8 @@ async def test_assist_in_progress_issue_deleted( Remove this cleanup after 2026.4 """ entry = entity_registry.async_get_or_create( - domain=DOMAIN, - platform="binary_sensor", + domain="binary_sensor", + platform=DOMAIN, unique_id="11:22:33:44:55:AA-assist_in_progress", ) ir.async_create_issue( @@ -1956,7 +1956,7 @@ async def test_assist_in_progress_issue_deleted( ) assert ( entity_registry.async_get_entity_id( - DOMAIN, "binary_sensor", "11:22:33:44:55:AA-assist_in_progress" + "binary_sensor", DOMAIN, "11:22:33:44:55:AA-assist_in_progress" ) is None ) From 34e2f77161e8674372e668b761baf29e43702c29 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 7 Jul 2026 23:14:25 +0200 Subject: [PATCH 198/707] Bump `ha-iotawattpy` from 0.1.2 to 0.2.1 (#175897) --- homeassistant/components/iotawatt/coordinator.py | 5 ++++- homeassistant/components/iotawatt/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/iotawatt/test_init.py | 12 ++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index e751f511043b..d2089c236503 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -78,6 +78,9 @@ class IotawattUpdater(DataUpdateCoordinator): self.api = api - await self.api.update(lastUpdate=self._last_run) + try: + await self.api.update(lastUpdate=self._last_run) + except CONNECTION_ERRORS as err: + raise UpdateFailed("Connection failed") from err self._last_run = None return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index f6f9efb16320..ac0d705ab898 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["iotawattpy"], - "requirements": ["ha-iotawattpy==0.1.2"] + "requirements": ["ha-iotawattpy==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf70a636c45b..109220ef27a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ h2==4.3.0 ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt -ha-iotawattpy==0.1.2 +ha-iotawattpy==0.2.1 # homeassistant.components.philips_js ha-philipsjs==3.2.4 diff --git a/tests/components/iotawatt/test_init.py b/tests/components/iotawatt/test_init.py index af4bc64cc54d..75427ce889ef 100644 --- a/tests/components/iotawatt/test_init.py +++ b/tests/components/iotawatt/test_init.py @@ -42,3 +42,15 @@ async def test_setup_auth_failed( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_update_failed( + hass: HomeAssistant, mock_iotawatt: MagicMock, entry: MockConfigEntry +) -> None: + """Test error while fetching sensor data during startup.""" + mock_iotawatt.update.side_effect = httpx.HTTPStatusError( + "", request=MagicMock(), response=MagicMock() + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_RETRY From 270c728ae470fbc125016f01b6f749d79ad29e95 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Tue, 7 Jul 2026 23:31:08 +0200 Subject: [PATCH 199/707] Change toggle service icon from play-pause to power (#175901) --- homeassistant/components/media_player/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 789c2c064f5f..fad4025e6f7f 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -113,7 +113,7 @@ "service": "mdi:shuffle" }, "toggle": { - "service": "mdi:play-pause" + "service": "mdi:power" }, "turn_off": { "service": "mdi:power" From 94c37d9d6228d71ffece0f00dc3bd0b58fd8c3aa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Jul 2026 23:31:52 +0200 Subject: [PATCH 200/707] Move DEFAULT_RADIUS to const.py to drop zone inline imports in core_config (#175896) Co-authored-by: Claude --- homeassistant/components/zone/__init__.py | 2 +- homeassistant/const.py | 3 +++ homeassistant/core_config.py | 6 +----- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 18f7e6c34ad5..b0a14960fca7 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, + DEFAULT_RADIUS, EVENT_CORE_CONFIG_UPDATE, SERVICE_RELOAD, STATE_UNAVAILABLE, @@ -49,7 +50,6 @@ from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE _LOGGER = logging.getLogger(__name__) DEFAULT_PASSIVE = False -DEFAULT_RADIUS = 100 ENTITY_ID_FORMAT = "zone.{}" ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE) diff --git a/homeassistant/const.py b/homeassistant/const.py index 326742e062e1..587f83d70f33 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -48,6 +48,9 @@ ENTITY_MATCH_ANY: Final = "any" # If no name is specified DEVICE_DEFAULT_NAME: Final = "Unnamed Device" +# Default radius of the Home Zone (in meters) +DEFAULT_RADIUS: Final = 100 + # Max characters for data stored in the recorder (changes to these limits would require # a database migration) MAX_LENGTH_EVENT_EVENT_TYPE: Final = 64 diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index de958b97d417..82413efcc721 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -49,6 +49,7 @@ from .const import ( CONF_UNIT_SYSTEM, CONF_URL, CONF_USERNAME, + DEFAULT_RADIUS, EVENT_CORE_CONFIG_UPDATE, KEY_DATA_LOGGING_DISABLED_REASON, LEGACY_CONF_WHITELIST_EXTERNAL_DIRS, @@ -538,8 +539,6 @@ class Config: def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" - from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 - self.hass = hass self.latitude: float = 0 @@ -858,9 +857,6 @@ class Config: old_data: dict[str, Any], ) -> dict[str, Any]: """Migrate to the new version.""" - - from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 - data = old_data if old_major_version == 1 and old_minor_version < 2: # In 1.2, we remove support for "imperial", replaced by "us_customary" From 12b69b9b18f9fd9276c0cfaebd01a4fc9c7ea202 Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Tue, 7 Jul 2026 23:49:03 +0200 Subject: [PATCH 201/707] Add diagnostics support for stiebel_eltron integration (#175905) Co-authored-by: Claude Fable 5 --- .../components/stiebel_eltron/diagnostics.py | 35 +++++++++++++++++++ .../stiebel_eltron/quality_scale.yaml | 2 +- tests/components/stiebel_eltron/conftest.py | 7 +++- .../snapshots/test_diagnostics.ambr | 22 ++++++++++++ .../stiebel_eltron/test_diagnostics.py | 26 ++++++++++++++ 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/stiebel_eltron/diagnostics.py create mode 100644 tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr create mode 100644 tests/components/stiebel_eltron/test_diagnostics.py diff --git a/homeassistant/components/stiebel_eltron/diagnostics.py b/homeassistant/components/stiebel_eltron/diagnostics.py new file mode 100644 index 000000000000..483968dca5c6 --- /dev/null +++ b/homeassistant/components/stiebel_eltron/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for STIEBEL ELTRON.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import StiebelEltronConfigEntry + +TO_REDACT = {CONF_HOST} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "model": coordinator.device_info["model"], + "modbus": { + "is_connected": coordinator.api_client.is_connected, + }, + "data": { + "current_temp": coordinator.api_client.get_current_temp(), + "target_temp": coordinator.api_client.get_target_temp(), + "current_humidity": coordinator.api_client.get_current_humidity(), + "operating_mode": coordinator.api_client.get_operation().name, + "heating_status": coordinator.api_client.get_heating_status(), + "cooling_status": coordinator.api_client.get_cooling_status(), + "filter_alarm": coordinator.api_client.get_filter_alarm_status(), + }, + } diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml index fb718177cb62..83242da57564 100644 --- a/homeassistant/components/stiebel_eltron/quality_scale.yaml +++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml @@ -47,7 +47,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: todo diff --git a/tests/components/stiebel_eltron/conftest.py b/tests/components/stiebel_eltron/conftest.py index 5c43aab97196..489377acd783 100644 --- a/tests/components/stiebel_eltron/conftest.py +++ b/tests/components/stiebel_eltron/conftest.py @@ -44,9 +44,14 @@ def mock_lwz_api() -> Generator[MagicMock]: api_client.get_current_temp = MagicMock(return_value=21.0) api_client.get_current_humidity = MagicMock(return_value=45.0) api_client.get_operation = MagicMock(return_value=OperatingMode.AUTOMATIC) + api_client.get_heating_status = MagicMock(return_value=True) + api_client.get_cooling_status = MagicMock(return_value=False) api_client.get_filter_alarm_status = MagicMock(return_value=False) - api_client.connect = AsyncMock() + def _connect() -> None: + api_client.is_connected = True + + api_client.connect = AsyncMock(side_effect=_connect) api_client.close = AsyncMock() api_client.async_update = AsyncMock() api_client.set_operation = AsyncMock() diff --git a/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr b/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..004cc7fa8435 --- /dev/null +++ b/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'cooling_status': False, + 'current_humidity': 45.0, + 'current_temp': 21.0, + 'filter_alarm': False, + 'heating_status': True, + 'operating_mode': 'AUTOMATIC', + 'target_temp': 22.5, + }), + 'entry_data': dict({ + 'host': '**REDACTED**', + 'port': 502, + }), + 'modbus': dict({ + 'is_connected': True, + }), + 'model': 'LWZ', + }) +# --- diff --git a/tests/components/stiebel_eltron/test_diagnostics.py b/tests/components/stiebel_eltron/test_diagnostics.py new file mode 100644 index 000000000000..bc1c820fb1d0 --- /dev/null +++ b/tests/components/stiebel_eltron/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for STIEBEL ELTRON diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 676f18c0960dbbaa0f0bac70f7b29d0014239be1 Mon Sep 17 00:00:00 2001 From: Vincent Courcelle <2070309+tubededentifrice@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:44:36 +0400 Subject: [PATCH 202/707] Add Roborock Q10 map image entity (#173883) --- homeassistant/components/roborock/icons.json | 5 ++ homeassistant/components/roborock/image.py | 79 +++++++++++++++---- .../components/roborock/strings.json | 5 ++ tests/components/roborock/conftest.py | 1 + tests/components/roborock/test_image.py | 56 ++++++++++++- 5 files changed, 130 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index 76ba0b47a925..bc018d23789b 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -40,6 +40,11 @@ "default": "mdi:brush" } }, + "image": { + "map": { + "default": "mdi:floor-plan" + } + }, "number": { "volume": { "default": "mdi:volume-source" diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 6c9e2327e4cd..913eab5c8b1c 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -14,13 +14,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import ( + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockCoordinatorType, RoborockDataUpdateCoordinator, ) -from .entity import RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1 _LOGGER = logging.getLogger(__name__) @@ -40,20 +42,22 @@ async def async_setup_entry( coordinator: RoborockCoordinatorType, ) -> None: """Add entities for a specific coordinator.""" - if not isinstance(coordinator, RoborockDataUpdateCoordinator): - return - entities = [ - RoborockMap( - config_entry, - coordinator, - coordinator.properties_api.home, - map_info.map_flag, - map_info.name, + entities: list[ImageEntity] = [] + if isinstance(coordinator, RoborockDataUpdateCoordinator): + entities.extend( + RoborockMap( + config_entry, + coordinator, + coordinator.properties_api.home, + map_info.map_flag, + map_info.name, + ) + for map_info in ( + coordinator.properties_api.home.home_map_info or {} + ).values() ) - for map_info in ( - coordinator.properties_api.home.home_map_info or {} - ).values() - ] + elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator): + entities.append(RoborockMapQ10(coordinator)) async_add_entities(entities) for coordinator in coordinators.values(): @@ -134,3 +138,50 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): if (map_content := self._map_content) is None: raise HomeAssistantError("Map flag not found in coordinator maps") return map_content.image_content + + +class RoborockMapQ10(RoborockCoordinatedEntityB01Q10, ImageEntity): + """A class to let you visualize the current map of a Q10 device. + + The Q10 pushes its current map over MQTT rather than serving it on + request, and the multi-map list is not reachable on this channel, so the + device exposes a single push-driven map entity. + """ + + _attr_content_type = "image/png" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "map" + + def __init__(self, coordinator: RoborockB01Q10UpdateCoordinator) -> None: + """Initialize a Roborock Q10 map.""" + RoborockCoordinatedEntityB01Q10.__init__( + self, f"map_{coordinator.duid_slug}", coordinator + ) + ImageEntity.__init__(self, coordinator.hass) + self._map_trait = coordinator.api.map + self._cached_map: bytes | None = None + + @override + async def async_added_to_hass(self) -> None: + """Register a trait listener for push-based map updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self._map_trait.add_update_listener(self._handle_map_update) + ) + # Pick up a map that was pushed before the entity was added. + self._handle_map_update() + + @callback + def _handle_map_update(self) -> None: + """Cache the newly pushed map if its content changed.""" + image_content = self._map_trait.image_content + if image_content is None or image_content == self._cached_map: + return + self._cached_map = image_content + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + + @override + async def async_image(self) -> bytes | None: + """Get the cached image.""" + return self._cached_map diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7f4b524dc088..3d496b9738bc 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -125,6 +125,11 @@ "name": "Start" } }, + "image": { + "map": { + "name": "Map" + } + }, "number": { "volume": { "name": "Volume" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 0a1d4aeb3ec0..3f73ade734c4 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -239,6 +239,7 @@ def create_b01_q10_trait() -> Mock: q10_trait.button_light.disable = AsyncMock() q10_trait.map = Mock() + q10_trait.map.image_content = b"\x89PNG-q10" q10_trait.map.rooms = [ Q10Room(id=9, raw_name="rr_bedroom", pixel_value=36, pixel_count=100), Q10Room(id=10, raw_name="rr_living_room", pixel_value=40, pixel_count=200), diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 7ddd50de97ab..6168c04d7b20 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from roborock import MultiMapsList, RoborockException from roborock.data import RoborockStateCode @@ -45,7 +46,7 @@ async def test_floorplan_image( fake_devices: list[FakeDevice], ) -> None: """Test floor plan map image is correctly set up.""" - assert len(hass.states.async_all("image")) == 4 + assert len(hass.states.async_all("image")) == 5 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None # Load the image on demand @@ -131,7 +132,7 @@ async def test_map_status_change( fake_vacuum: FakeDevice, ) -> None: """Test floor plan map image is correctly updated on status change.""" - assert len(hass.states.async_all("image")) == 4 + assert len(hass.states.async_all("image")) == 5 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None client = await hass_client() @@ -181,6 +182,7 @@ async def test_map_status_change( "image.roborock_s7_2_upstairs", "image.roborock_s7_maxv_downstairs", "image.roborock_s7_maxv_upstairs", + "image.roborock_q10_s5_map", }, ), ( @@ -191,6 +193,7 @@ async def test_map_status_change( # Expect default names based on map flags "image.roborock_s7_maxv_map_0", "image.roborock_s7_maxv_map_1", + "image.roborock_q10_s5_map", }, ), ], @@ -222,3 +225,52 @@ async def test_image_entity_naming( assert { state.entity_id for state in hass.states.async_all("image") } == expected_entity_ids + + +async def test_q10_map_image( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + fake_q10_vacuum: FakeDevice, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the push-driven Q10 map image.""" + entity_id = "image.roborock_q10_s5_map" + assert hass.states.get(entity_id) is not None + + # The map pushed before startup is served + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{entity_id}") + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"\x89PNG-q10" + + assert fake_q10_vacuum.b01_q10_properties is not None + map_trait = fake_q10_vacuum.b01_q10_properties.map + + def push_update() -> None: + for call in map_trait.add_update_listener.call_args_list: + call.args[0]() + + # A push that does not change the map content must not update the entity + state = hass.states.get(entity_id) + assert state is not None + last_updated = state.state + freezer.tick(timedelta(seconds=30)) + push_update() + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == last_updated + + # The device pushes an updated map + freezer.tick(timedelta(seconds=30)) + map_trait.image_content = b"\x89PNG-q10-new" + push_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != last_updated + resp = await client.get(f"/api/image_proxy/{entity_id}") + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"\x89PNG-q10-new" From 53f073677c7b87ab48bf5836d489c9e4d1262b26 Mon Sep 17 00:00:00 2001 From: Vincent Courcelle <2070309+tubededentifrice@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:48:00 +0400 Subject: [PATCH 203/707] Add Roborock Q10 volume number entity (#175732) --- homeassistant/components/roborock/number.py | 111 +++++++++++++++++--- tests/components/roborock/conftest.py | 9 ++ tests/components/roborock/test_number.py | 68 ++++++++++++ 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 67dff3ae6b7d..e05884d945b5 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -5,6 +5,8 @@ from dataclasses import dataclass import logging from typing import Any, override +from roborock.devices.traits.b01 import Q10PropertiesApi +from roborock.devices.traits.b01.q10 import SoundVolumeTrait from roborock.devices.traits.v1 import PropertiesApi from roborock.exceptions import RoborockException @@ -17,11 +19,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ( + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockCoordinatorType, RoborockDataUpdateCoordinator, ) -from .entity import RoborockEntityV1 +from .entity import RoborockCoordinatedEntityB01Q10, RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -59,6 +62,37 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ ] +@dataclass(frozen=True, kw_only=True) +class RoborockNumberDescriptionQ10(NumberEntityDescription): + """Class to describe a Roborock Q10 number entity.""" + + trait: Callable[[Q10PropertiesApi], SoundVolumeTrait | None] + """Function to get the trait backing the entity, if supported.""" + + get_value: Callable[[SoundVolumeTrait], float | None] + """Function to get the value from the trait.""" + + set_value: Callable[[SoundVolumeTrait, float], Coroutine[Any, Any, None]] + """Function to set the value on the trait.""" + + +Q10_NUMBER_DESCRIPTIONS: list[RoborockNumberDescriptionQ10] = [ + RoborockNumberDescriptionQ10( + key="volume", + translation_key="volume", + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + trait=lambda api: api.volume, + get_value=lambda trait: ( + float(trait.volume) if trait.volume is not None else None + ), + set_value=lambda trait, value: trait.set_volume(int(value)), + ) +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -72,18 +106,29 @@ async def async_setup_entry( coordinator: RoborockCoordinatorType, ) -> None: """Add entities for a specific coordinator.""" - if not isinstance(coordinator, RoborockDataUpdateCoordinator): - return - entities = [ - RoborockNumberEntity( - f"{description.key}_{coordinator.duid_slug}", - coordinator=coordinator, - entity_description=description, - trait=trait, + entities: list[NumberEntity] = [] + if isinstance(coordinator, RoborockDataUpdateCoordinator): + entities.extend( + RoborockNumberEntity( + f"{description.key}_{coordinator.duid_slug}", + coordinator=coordinator, + entity_description=description, + trait=trait, + ) + for description in NUMBER_DESCRIPTIONS + if (trait := description.trait(coordinator.properties_api)) is not None + ) + elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator): + entities.extend( + RoborockNumberEntityQ10( + f"{description.key}_{coordinator.duid_slug}", + coordinator=coordinator, + entity_description=description, + trait=q10_trait, + ) + for description in Q10_NUMBER_DESCRIPTIONS + if (q10_trait := description.trait(coordinator.api)) is not None ) - for description in NUMBER_DESCRIPTIONS - if (trait := description.trait(coordinator.properties_api)) is not None - ] async_add_entities(entities) for coordinator in coordinators.values(): @@ -133,3 +178,45 @@ class RoborockNumberEntity(RoborockEntityV1, NumberEntity): translation_domain=DOMAIN, translation_key="update_options_failed", ) from err + + +class RoborockNumberEntityQ10(RoborockCoordinatedEntityB01Q10, NumberEntity): + """A class to set a numeric setting on a Roborock Q10 device.""" + + entity_description: RoborockNumberDescriptionQ10 + coordinator: RoborockB01Q10UpdateCoordinator + + def __init__( + self, + unique_id: str, + coordinator: RoborockB01Q10UpdateCoordinator, + entity_description: RoborockNumberDescriptionQ10, + trait: SoundVolumeTrait, + ) -> None: + """Create a number entity.""" + self.entity_description = entity_description + self._trait = trait + super().__init__(unique_id, coordinator) + + @override + async def async_added_to_hass(self) -> None: + """Register a trait listener for push-based state updates.""" + await super().async_added_to_hass() + self.async_on_remove(self._trait.add_update_listener(self.async_write_ha_state)) + + @property + @override + def native_value(self) -> float | None: + """Get native value.""" + return self.entity_description.get_value(self._trait) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set number value.""" + try: + await self.entity_description.set_value(self._trait, value) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 3f73ade734c4..43e29665ac29 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -238,6 +238,15 @@ def create_b01_q10_trait() -> Mock: q10_trait.button_light.enable = AsyncMock() q10_trait.button_light.disable = AsyncMock() + q10_trait.volume = AsyncMock() + q10_trait.volume.volume = 50 + volume_notify = attach_update_listeners(q10_trait.volume) + + async def _set_volume(volume: int) -> None: + q10_trait.volume.volume = volume + volume_notify() + + q10_trait.volume.set_volume = AsyncMock(side_effect=_set_volume) q10_trait.map = Mock() q10_trait.map.image_content = b"\x89PNG-q10" q10_trait.map.rooms = [ diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index c5ce70d5b071..8a269a3ef040 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -50,6 +50,74 @@ async def test_update_sound_volume( assert state.state == "3.0" +async def test_q10_update_sound_volume( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test changing the volume of a Q10 device.""" + entity_id = "number.roborock_q10_s5_volume" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "50.0" + + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 30.0}, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.volume.set_volume.assert_awaited_once_with(30) + + # The trait listener pushes the new value into the entity state + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "30.0" + + +async def test_q10_volume_unknown_value( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test the Q10 entity reports unknown when the trait value is None.""" + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.volume.volume = None + + await async_update_entity(hass, "number.roborock_q10_s5_volume") + + state = hass.states.get("number.roborock_q10_s5_volume") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_q10_volume_update_failed( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test a failure while changing the volume of a Q10 device.""" + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.volume.set_volume.side_effect = RoborockTimeout + + assert hass.states.get("number.roborock_q10_s5_volume") is not None + + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 30.0}, + blocking=True, + target={"entity_id": "number.roborock_q10_s5_volume"}, + ) + + fake_q10_vacuum.b01_q10_properties.volume.set_volume.assert_awaited_once_with(30) + + async def test_volume_unknown_value( hass: HomeAssistant, setup_entry: MockConfigEntry, From 306963a0ebafddd8cddd45b976220a11b56cc4e9 Mon Sep 17 00:00:00 2001 From: Sarabveer Singh <4297171+sarabveer@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:01:58 -0400 Subject: [PATCH 204/707] Bump tesla-wall-connector to 1.2.0 (#175912) --- homeassistant/components/tesla_wall_connector/manifest.json | 2 +- requirements_all.txt | 2 +- script/hassfest/requirements.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json index d008d99f1c16..10d32279cde0 100644 --- a/homeassistant/components/tesla_wall_connector/manifest.json +++ b/homeassistant/components/tesla_wall_connector/manifest.json @@ -21,5 +21,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["tesla_wall_connector"], - "requirements": ["tesla-wall-connector==1.1.0"] + "requirements": ["tesla-wall-connector==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 109220ef27a7..3a4e584a6f6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3159,7 +3159,7 @@ tesla-fleet-api==1.5.4 tesla-powerwall==0.5.3 # homeassistant.components.tesla_wall_connector -tesla-wall-connector==1.1.0 +tesla-wall-connector==1.2.0 # homeassistant.components.teslemetry teslemetry-stream==0.9.1 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 5ce6510ebd7b..7aba0b5e27df 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -217,7 +217,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "surepetcare": {"surepy": {"async-timeout"}}, "tailwind": {"gotailwind": {"backoff"}}, "technove": {"python-technove": {"backoff"}}, - "tesla_wall_connector": {"tesla-wall-connector": {"backoff"}}, "tibber": {"gql": {"backoff"}}, "toon": {"toonapi": {"backoff"}}, "travisci": { From 1313bad843ff6769fab55c9bcfcc9b5af1ce2124 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:03:07 +0200 Subject: [PATCH 205/707] Update uv to 0.11.26 (#175919) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1673fadae40c..5353f78036a7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.25 +uv==0.11.26 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 112eb25df626..4a86e436c694 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.25", + "uv==0.11.26", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index fd856a0670e2..aaea226863b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.25 +uv==0.11.26 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 From 469efbde99c37e80166a070d611ec2360eb6b24b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 7 Jul 2026 22:11:20 -0700 Subject: [PATCH 206/707] Pin cffi to 2.0.0 in package constraints (#175907) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5353f78036a7..7f2f12be77ae 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -256,3 +256,7 @@ azure-kusto-data==4.5.1 azure-kusto-ingest==4.5.1 coloredlogs==15.0.1 setuptools==81.0.0 + +# Pin cffi to 2.0.0 to avoid version mismatch with the pre-baked _cffi_backend in the base image. +# https://github.com/home-assistant/core/issues/175832 +cffi==2.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 63de1db99152..e70e25cf2d87 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -239,6 +239,10 @@ azure-kusto-data==4.5.1 azure-kusto-ingest==4.5.1 coloredlogs==15.0.1 setuptools==81.0.0 + +# Pin cffi to 2.0.0 to avoid version mismatch with the pre-baked _cffi_backend in the base image. +# https://github.com/home-assistant/core/issues/175832 +cffi==2.0.0 """ GENERATED_MESSAGE = ( From 66d99a1a0610c9fc1f6f10ed8c38dc38127c028f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 8 Jul 2026 15:17:40 +1000 Subject: [PATCH 207/707] Fix Teslemetry insufficient-credits polling storm (#175913) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/teslemetry/coordinator.py | 11 +++++ .../components/teslemetry/strings.json | 3 ++ tests/components/teslemetry/test_init.py | 44 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 0dffd2b73f6b..fa80e55ddb6f 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, override from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( GatewayTimeout, + InsufficientCredits, InvalidResponse, InvalidToken, LoginRequired, @@ -49,6 +50,10 @@ ENERGY_INFO_INTERVAL = timedelta(seconds=30) ENERGY_HISTORY_INTERVAL = timedelta(seconds=60) METADATA_INTERVAL = timedelta(hours=1) +# Insufficient credits will not resolve themselves quickly, so back off polling +# instead of hammering the API at the coordinator's normal interval. +INSUFFICIENT_CREDITS_RETRY_AFTER = timedelta(hours=1).total_seconds() + ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, VehicleDataEndpoint.CLIMATE_STATE, @@ -139,6 +144,12 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e + except InsufficientCredits as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_insufficient_credits", + retry_after=INSUFFICIENT_CREDITS_RETRY_AFTER, + ) from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index cc39dad96bef..e7f9f6dff25f 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1197,6 +1197,9 @@ "update_failed": { "message": "Error fetching data from Teslemetry API: {message}" }, + "update_failed_insufficient_credits": { + "message": "Teslemetry account has insufficient command credits, pausing updates until credits are added" + }, "update_failed_invalid_data": { "message": "Received invalid data from API" }, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 8088ee094202..947998c1db10 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -10,6 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( Forbidden, + InsufficientCredits, InvalidResponse, InvalidToken, RateLimited, @@ -24,6 +25,7 @@ from homeassistant.components.teslemetry.coordinator import ( ENERGY_HISTORY_INTERVAL, ENERGY_INFO_INTERVAL, ENERGY_LIVE_INTERVAL, + INSUFFICIENT_CREDITS_RETRY_AFTER, METADATA_INTERVAL, VEHICLE_INTERVAL, ) @@ -38,6 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import UpdateFailed from . import setup_platform from .const import ( @@ -62,6 +65,11 @@ ERRORS = [ (TeslaFleetError, ConfigEntryState.SETUP_RETRY), ] +VEHICLE_ERRORS = [ + *ERRORS, + (InsufficientCredits, ConfigEntryState.SETUP_RETRY), +] + async def test_load_unload(hass: HomeAssistant) -> None: """Test load and unload.""" @@ -101,7 +109,7 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +@pytest.mark.parametrize(("side_effect", "state"), VEHICLE_ERRORS) async def test_vehicle_refresh_error( hass: HomeAssistant, mock_vehicle_data: AsyncMock, @@ -996,3 +1004,37 @@ async def test_dynamic_device_discovery_no_reload_without_changes( # Verify reload was NOT triggered since no subscription changes mock_reload.assert_not_called() + + +async def test_insufficient_credits_backs_off_polling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Running out of command credits should back off, not hammer the API every poll.""" + call_count = 0 + + def vehicle_data_side_effect(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return deepcopy(VEHICLE_DATA) + raise InsufficientCredits + + mock_vehicle_data.side_effect = vehicle_data_side_effect + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert call_count == 1 + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert call_count == 2 + assert entry.state is ConfigEntryState.LOADED + + coordinator = entry.runtime_data.vehicles[0].coordinator + assert isinstance(coordinator.last_exception, UpdateFailed) + assert coordinator.last_exception.retry_after == INSUFFICIENT_CREDITS_RETRY_AFTER From e7fdc913de9f93e420b6c121ed61629f301df14e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:19:12 +0200 Subject: [PATCH 208/707] Migrate input_boolean entity attributes to StrEnum (#175739) --- homeassistant/components/input_boolean/__init__.py | 8 +++++--- homeassistant/components/input_boolean/const.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/input_boolean/const.py diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 74754be4ed06..68e18e3d1d0f 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -5,7 +5,7 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, CONF_ICON, CONF_ID, @@ -25,6 +25,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputBooleanEntityStateAttribute + DOMAIN = "input_boolean" _LOGGER = logging.getLogger(__name__) @@ -146,7 +148,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity): """Representation of a boolean input.""" - _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _unrecorded_attributes = frozenset({InputBooleanEntityStateAttribute.EDITABLE}) _attr_should_poll = False editable: bool @@ -190,7 +192,7 @@ class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity): @override def extra_state_attributes(self) -> dict[str, bool]: """Return the state attributes of the entity.""" - return {ATTR_EDITABLE: self.editable} + return {InputBooleanEntityStateAttribute.EDITABLE: self.editable} @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/input_boolean/const.py b/homeassistant/components/input_boolean/const.py new file mode 100644 index 000000000000..c2be02dff532 --- /dev/null +++ b/homeassistant/components/input_boolean/const.py @@ -0,0 +1,9 @@ +"""Constants for the input_boolean integration.""" + +from enum import StrEnum + + +class InputBooleanEntityStateAttribute(StrEnum): + """State attributes for input boolean entities.""" + + EDITABLE = "editable" From f662f0c7f25e703a8491f66d68390cc07c8edce2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:34:04 +0200 Subject: [PATCH 209/707] Update infrared-protocols to 6.6.0 (#175927) --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 319e3c63d804..686c88c5cff6 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==6.5.0"] + "requirements": ["infrared-protocols==6.6.0"] } diff --git a/requirements.txt b/requirements.txt index aaea226863b8..f71bd2c24e23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==6.5.0 +infrared-protocols==6.6.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index 3a4e584a6f6e..aea5a4690c64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1362,7 +1362,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==6.5.0 +infrared-protocols==6.6.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 From 48c57e9d2a784c3bbb0bfe3e16261a6f7d6439c0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:44:33 +0200 Subject: [PATCH 210/707] Use HumidifierEntityStateAttribute enum in generic_hygrostat (#175873) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/generic_hygrostat/humidifier.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 3d4781d43ed9..ef9acaaac724 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -7,7 +7,6 @@ import logging from typing import TYPE_CHECKING, Any, cast, override from homeassistant.components.humidifier import ( - ATTR_HUMIDITY, MODE_AWAY, MODE_NORMAL, PLATFORM_SCHEMA as HUMIDIFIER_PLATFORM_SCHEMA, @@ -15,11 +14,11 @@ from homeassistant.components.humidifier import ( HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, + HumidifierEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_MODE, CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, @@ -266,12 +265,17 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) if (old_state := await self.async_get_last_state()) is not None: - if old_state.attributes.get(ATTR_MODE) == MODE_AWAY: + if ( + old_state.attributes.get(HumidifierEntityStateAttribute.MODE) + == MODE_AWAY + ): self._is_away = True self._saved_target_humidity = self._target_humidity self._target_humidity = self._away_humidity or self._target_humidity - if old_state.attributes.get(ATTR_HUMIDITY): - self._target_humidity = int(old_state.attributes[ATTR_HUMIDITY]) + if old_state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY): + self._target_humidity = int( + old_state.attributes[HumidifierEntityStateAttribute.HUMIDITY] + ) if old_state.attributes.get(ATTR_SAVED_HUMIDITY): self._saved_target_humidity = int( old_state.attributes[ATTR_SAVED_HUMIDITY] From 96e53303ceea40c6871d3efba68b9e8433f87c7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:28:42 +0200 Subject: [PATCH 211/707] Bump docker/build-push-action from 7.2.0 to 7.3.0 (#175931) --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ab765750ccdd..6ee9ad951d96 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -528,7 +528,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -541,7 +541,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 0af2495c0ad5ed6df77fe718e6476a20a8918030 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:39:27 +0200 Subject: [PATCH 212/707] Use ClimateEntityStateAttribute enum in generic_thermostat (#175874) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/generic_thermostat/climate.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 3828ea00945e..31491b3306c2 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -13,11 +13,11 @@ import voluptuous as vol from homeassistant.components.climate import ( ATTR_HVAC_MODE, - ATTR_PRESET_MODE, PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, PRESET_NONE, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -351,7 +351,10 @@ class GenericThermostat(ClimateEntity, RestoreEntity): # If we have no initial temperature, restore if self._target_temp is None: # If we have a previously saved temperature - if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if ( + old_state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE) + is None + ): if self.ac_mode: self._target_temp = self.max_temp else: @@ -361,12 +364,17 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._target_temp, ) else: - self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) + self._target_temp = float( + old_state.attributes[ClimateEntityStateAttribute.TEMPERATURE] + ) if ( self.preset_modes - and old_state.attributes.get(ATTR_PRESET_MODE) in self.preset_modes + and old_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + in self.preset_modes ): - self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + self._attr_preset_mode = old_state.attributes.get( + ClimateEntityStateAttribute.PRESET_MODE + ) if not self._hvac_mode and old_state.state: self._hvac_mode = HVACMode(old_state.state) From 0fa728ed8170ecebc013c88a4d943dd81f52b96d Mon Sep 17 00:00:00 2001 From: cdheiser <10488026+cdheiser@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:42:01 -0700 Subject: [PATCH 213/707] Bump pylutron to 0.4.2 (#175930) --- homeassistant/components/lutron/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index b08676082cba..ae234713e72a 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.4.1"], + "requirements": ["pylutron==0.4.2"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index aea5a4690c64..76c35f7274e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2338,7 +2338,7 @@ pylitterbot==2025.5.0 pylutron-caseta==0.28.0 # homeassistant.components.lutron -pylutron==0.4.1 +pylutron==0.4.2 # homeassistant.components.mailgun pymailgunner==1.4 From 5d91dcf056b427752165e09e5709f792c3bd55b4 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 08:46:36 +0200 Subject: [PATCH 214/707] MELCloud Home add e-mail to reauth form (#175899) --- homeassistant/components/melcloud_home/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud_home/config_flow.py b/homeassistant/components/melcloud_home/config_flow.py index 17d97ff86f7b..55af4c2e1b48 100644 --- a/homeassistant/components/melcloud_home/config_flow.py +++ b/homeassistant/components/melcloud_home/config_flow.py @@ -128,6 +128,9 @@ class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", - data_schema=STEP_USER_DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {CONF_EMAIL: reauth_entry.data[CONF_EMAIL]}, + ), errors=errors, ) From 5d02a743591f013e0e33ee129961cc8a20a72ece Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Wed, 8 Jul 2026 08:48:37 +0200 Subject: [PATCH 215/707] Add DHCP discovery to stiebel_eltron integration (#175909) Co-authored-by: Claude Fable 5 --- .../components/stiebel_eltron/config_flow.py | 37 ++++++++ .../components/stiebel_eltron/manifest.json | 5 ++ .../stiebel_eltron/quality_scale.yaml | 4 +- .../components/stiebel_eltron/strings.json | 4 + homeassistant/generated/dhcp.py | 4 + .../stiebel_eltron/test_config_flow.py | 87 ++++++++++++++++++- 6 files changed, 138 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py index 89b58dc9f1af..04fe34dcde32 100644 --- a/homeassistant/components/stiebel_eltron/config_flow.py +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -8,12 +8,14 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, TextSelector, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DEFAULT_PORT, DOMAIN @@ -50,6 +52,41 @@ class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _discovered_host: str + + @override + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + + error = await check_controller_model(discovery_info.ip, DEFAULT_PORT) + if error is not None: + return self.async_abort(reason=error) + + self._discovered_host = discovery_info.ip + self.context["title_placeholders"] = {CONF_HOST: discovery_info.ip} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the discovered device.""" + if user_input is not None: + return self.async_create_entry( + title="Stiebel Eltron", + data={CONF_HOST: self._discovered_host, CONF_PORT: DEFAULT_PORT}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={CONF_HOST: self._discovered_host}, + ) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 21f7e859b03c..13603b7f3500 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -3,6 +3,11 @@ "name": "STIEBEL ELTRON", "codeowners": ["@fucm", "@ThyMYthOS"], "config_flow": true, + "dhcp": [ + { + "hostname": "servicewelt*" + } + ], "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml index 83242da57564..ca4e6a6579d3 100644 --- a/homeassistant/components/stiebel_eltron/quality_scale.yaml +++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml @@ -48,8 +48,8 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/stiebel_eltron/strings.json b/homeassistant/components/stiebel_eltron/strings.json index 9156f5f7f667..e10233150821 100644 --- a/homeassistant/components/stiebel_eltron/strings.json +++ b/homeassistant/components/stiebel_eltron/strings.json @@ -10,7 +10,11 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, + "flow_title": "STIEBEL ELTRON ({host})", "step": { + "discovery_confirm": { + "description": "Do you want to set up the STIEBEL ELTRON heat pump at {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 0cb446fd2c92..2c7dba6202db 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1058,6 +1058,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "my[45]50*", "macaddress": "001E0C*", }, + { + "domain": "stiebel_eltron", + "hostname": "servicewelt*", + }, { "domain": "sunricher_dali", "registered_devices": True, diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py index 6747a312d612..568d0e1897f5 100644 --- a/tests/components/stiebel_eltron/test_config_flow.py +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -6,15 +6,21 @@ from pystiebeleltron import ControllerModel, StiebelEltronModbusError import pytest from homeassistant.components.stiebel_eltron.const import DOMAIN -from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry USER_INPUT = {CONF_HOST: "1.1.1.1", CONF_PORT: 502} RECONFIGURE_INPUT = {CONF_HOST: "2.2.2.2", CONF_PORT: 502} +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.2", + hostname="servicewelt", + macaddress="000000000001", +) async def test_full_flow(hass: HomeAssistant) -> None: @@ -200,3 +206,82 @@ async def test_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_flow(hass: HomeAssistant) -> None: + """Test the full DHCP discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == {CONF_HOST: "1.1.1.2", CONF_PORT: 502} + assert result["result"].unique_id == "00:00:00:00:00:01" + + +async def test_dhcp_discovery_updates_host(hass: HomeAssistant) -> None: + """Test DHCP discovery updates the host of an entry with a matching MAC.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Stiebel Eltron", + data={CONF_HOST: "1.1.1.1", CONF_PORT: 502}, + unique_id="00:00:00:00:00:01", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == "1.1.1.2" + + +async def test_dhcp_discovery_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test DHCP discovery aborts for an already configured host.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="servicewelt", + macaddress="000000000001", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_reason"), + [ + pytest.param(StiebelEltronModbusError, "cannot_connect", id="cannot_connect"), + pytest.param(Exception, "unknown", id="unknown"), + ], +) +async def test_dhcp_discovery_errors( + hass: HomeAssistant, + mock_get_controller_model: MagicMock, + side_effect: type[Exception], + expected_reason: str, +) -> None: + """Test DHCP discovery aborts when the device cannot be validated.""" + mock_get_controller_model.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason From a73ed09e185a6c2a4da3a25fb5c09471b04d7ccf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jul 2026 01:52:24 -0500 Subject: [PATCH 216/707] Share HomeKit climate fan, swing, and temperature helpers (#175377) --- .../components/homekit/climate_base.py | 220 ++++++++++++++ .../components/homekit/climate_util.py | 189 ++++++++++++ .../components/homekit/type_thermostats.py | 276 ++++-------------- tests/components/homekit/test_climate_util.py | 94 ++++++ .../homekit/test_type_thermostats.py | 32 ++ 5 files changed, 596 insertions(+), 215 deletions(-) create mode 100644 homeassistant/components/homekit/climate_base.py create mode 100644 homeassistant/components/homekit/climate_util.py create mode 100644 tests/components/homekit/test_climate_util.py diff --git a/homeassistant/components/homekit/climate_base.py b/homeassistant/components/homekit/climate_base.py new file mode 100644 index 000000000000..2d4b13ec3daf --- /dev/null +++ b/homeassistant/components/homekit/climate_base.py @@ -0,0 +1,220 @@ +"""Base class shared by the climate accessory types.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_THERMOSTAT +from pyhap.service import Service + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + SERVICE_SET_SWING_MODE, + SWING_OFF, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.core import State + +from .accessories import HomeAccessory +from .climate_util import ( + fan_mode_to_speed, + fan_speed_to_mode, + get_fan_modes_and_speeds, + get_swing_off_mode, + get_swing_on_mode, + get_temperature_range_from_state, + is_swing_on, + resolve_target_temp_range, + temperature_attribute_to_homekit, +) +from .const import CHAR_CURRENT_TEMPERATURE, PROP_MAX_VALUE, PROP_MIN_VALUE +from .util import temperature_to_homekit, temperature_to_states + +_LOGGER = logging.getLogger(__name__) + + +class HomeKitClimateAccessory(HomeAccessory): + """Base class for the Thermostat and HeaterCooler accessories.""" + + # Configured by subclasses only when the entity exposes the mode. + char_speed: Characteristic + char_swing: Characteristic + + char_current_temp: Characteristic + + def __init__(self, *args: Any) -> None: + """Initialize the shared climate accessory state.""" + super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._unit = self.hass.config.units.temperature_unit + + state = self.hass.states.get(self.entity_id) + assert state + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # ``fan_modes`` maps lowercased names to their original casing; + # ``ordered_fan_speeds`` holds the predefined speeds in HomeKit order. + self.fan_modes: dict[str, str] = {} + self.ordered_fan_speeds: list[str] = [] + if features & ClimateEntityFeature.FAN_MODE: + self.fan_modes, self.ordered_fan_speeds = get_fan_modes_and_speeds( + attributes + ) + + self.swing_on_mode: str | None = None + self.swing_off_mode: str = SWING_OFF + if features & ClimateEntityFeature.SWING_MODE: + self.swing_on_mode = get_swing_on_mode(attributes) + self.swing_off_mode = get_swing_off_mode(attributes) + + # These attributes drive the characteristic set and valid values, so + # reload the accessory when any of them change. + self._reload_on_change_attrs.extend( + ( + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_HVAC_MODES, + ) + ) + + def get_temperature_range(self, state: State) -> tuple[float, float]: + """Return the min and max temperature range.""" + return get_temperature_range_from_state( + state, self._unit, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP + ) + + def _configure_current_temperature_char(self, serv: Service) -> None: + """Configure the shared current temperature characteristic.""" + self.char_current_temp = serv.configure_char( + CHAR_CURRENT_TEMPERATURE, value=21.0 + ) + + def _configure_target_mode_char( + self, + serv: Service, + char_name: str, + value: int, + valid_values: dict[HVACMode, int], + ) -> Characteristic: + """Configure a target mode characteristic scoped to the supported modes. + + The value must be set before ``valid_values`` because pyhap applies the + valid values first and would reject a default outside that set. + """ + char = serv.configure_char(char_name, value=value) + char.override_properties(valid_values=valid_values) + char.allow_invalid_client_values = True + return char + + def _update_temperature_char( + self, char: Characteristic, state: State, attr: str + ) -> None: + """Set a temperature characteristic from a state attribute, if present.""" + if ( + value := temperature_attribute_to_homekit(state, attr, self._unit) + ) is not None: + char.set_value(value) + + def _update_current_temperature_char(self, state: State) -> None: + """Update the current temperature characteristic from the entity state.""" + self._update_temperature_char( + self.char_current_temp, state, ATTR_CURRENT_TEMPERATURE + ) + + def _dual_setpoint_params( + self, + cool_char: Characteristic, + heat_char: Characteristic, + new_high: float | None, + new_low: float | None, + ) -> dict[str, float]: + """Return an ordered high/low target temperature pair for a range write. + + Fills the unchanged side from the current characteristic value and + enforces the deadband, so the entity always gets a consistent pair. + """ + high, low = resolve_target_temp_range( + cool_char.value, + heat_char.value, + new_high, + new_low, + cool_char.properties[PROP_MIN_VALUE], + cool_char.properties[PROP_MAX_VALUE], + ) + return { + ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high), + ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low), + } + + def _temperature_to_homekit(self, temp: float) -> float: + """Convert a temperature in the entity's unit to the HomeKit unit.""" + return temperature_to_homekit(temp, self._unit) + + def _temperature_to_states(self, temp: float) -> float: + """Convert a temperature in the HomeKit unit to the entity's unit.""" + return temperature_to_states(temp, self._unit) + + def _set_fan_speed(self, speed: int) -> None: + """Send the climate fan mode for a HomeKit rotation speed.""" + _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) + if not self.ordered_fan_speeds or not 0 < speed <= 100: + return + mode = fan_speed_to_mode(self.ordered_fan_speeds, self.fan_modes, speed) + self.async_call_service( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}, + ) + + def _set_swing_mode(self, swing_on: int) -> None: + """Send the climate swing mode for a HomeKit swing toggle.""" + if self.swing_on_mode is None: + return + _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) + mode = self.swing_on_mode if swing_on else self.swing_off_mode + self.async_call_service( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode}, + ) + + def _update_fan_speed_char(self, attributes: Mapping[str, Any]) -> None: + """Update the rotation speed characteristic from the current fan mode.""" + # Modes with no predefined speed (e.g. fan auto) keep the last value; + # HomeKit's slider has no position to represent them. + if ( + self.ordered_fan_speeds + and ( + speed := fan_mode_to_speed( + self.ordered_fan_speeds, attributes.get(ATTR_FAN_MODE) + ) + ) + is not None + ): + self.char_speed.set_value(speed) + + def _update_swing_char(self, attributes: Mapping[str, Any]) -> None: + """Update the swing characteristic from the current swing mode.""" + # An absent swing mode keeps the last value; there is nothing to show. + if self.swing_on_mode is not None and ( + swing_mode := attributes.get(ATTR_SWING_MODE) + ): + self.char_swing.set_value(1 if is_swing_on(swing_mode) else 0) diff --git a/homeassistant/components/homekit/climate_util.py b/homeassistant/components/homekit/climate_util.py new file mode 100644 index 000000000000..6731217fa1b6 --- /dev/null +++ b/homeassistant/components/homekit/climate_util.py @@ -0,0 +1,189 @@ +"""Shared fan, swing, and temperature helpers for the climate accessory types.""" + +from collections.abc import Iterable +from typing import Any + +from homeassistant.components.climate import ( + ATTR_FAN_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODES, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_ON, + SWING_VERTICAL, +) +from homeassistant.core import State +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .util import get_min_max, temperature_to_homekit + +ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH] +PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS) +SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL] +PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER) + +# Minimum gap kept between the low and high set points of a range. +HEAT_COOL_DEADBAND = 5 + + +def _lower_to_original(modes: Iterable[Any]) -> dict[str, str]: + """Map each string mode to its original casing, keyed by the lowercase form.""" + return {mode.lower(): mode for mode in modes if isinstance(mode, str)} + + +def get_fan_modes_and_speeds( + attributes: dict[str, Any], +) -> tuple[dict[str, str], list[str]]: + """Return the fan modes and ordered predefined speeds for a climate entity. + + ``fan_modes`` maps each lowercased fan mode to its original casing. + ``ordered_fan_speeds`` is the subset of predefined speeds the entity + exposes, in HomeKit rotation-speed order; it is empty when the entity only + advertises custom fan mode names. + """ + fan_modes = _lower_to_original(attributes.get(ATTR_FAN_MODES) or []) + ordered_fan_speeds: list[str] = [] + if PRE_DEFINED_FAN_MODES.intersection(fan_modes): + ordered_fan_speeds = [ + speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes + ] + return fan_modes, ordered_fan_speeds + + +def get_swing_on_mode(attributes: dict[str, Any]) -> str | None: + """Return the preferred swing-on mode for a climate entity, if any. + + The match is case insensitive and the entity's original casing is + returned so it can be sent back to the service. Returns ``None`` when the + entity exposes no predefined swing modes. + """ + if not (swing_modes := attributes.get(ATTR_SWING_MODES)): + return None + lower_to_original = _lower_to_original(swing_modes) + return next( + ( + lower_to_original[swing_mode] + for swing_mode in SWING_MODE_PREFERRED_ORDER + if swing_mode in lower_to_original + ), + None, + ) + + +def get_swing_off_mode(attributes: dict[str, Any]) -> str: + """Return the entity's off swing mode, preserving its original casing.""" + swing_modes = attributes.get(ATTR_SWING_MODES) or [] + return _lower_to_original(swing_modes).get(SWING_OFF, SWING_OFF) + + +def fan_speed_to_mode( + ordered_fan_speeds: list[str], fan_modes: dict[str, str], speed: int +) -> str: + """Return the climate fan mode for a HomeKit rotation speed percentage. + + The percentage is offset by one so the lowest slider step maps to the + first ordered speed. + """ + speed_key = percentage_to_ordered_list_item(ordered_fan_speeds, speed - 1) + return fan_modes[speed_key] + + +def fan_mode_to_speed(ordered_fan_speeds: list[str], fan_mode: Any) -> int | None: + """Return the HomeKit rotation speed percentage for a climate fan mode. + + Returns ``None`` when the mode is not one of the ordered predefined speeds. + """ + if ( + not isinstance(fan_mode, str) + or (fan_mode_lower := fan_mode.lower()) not in ordered_fan_speeds + ): + return None + return ordered_list_item_to_percentage(ordered_fan_speeds, fan_mode_lower) + + +def is_swing_on(swing_mode: Any) -> bool: + """Return whether a climate swing mode maps to HomeKit swing on.""" + return isinstance(swing_mode, str) and swing_mode.lower() in PRE_DEFINED_SWING_MODES + + +def get_temperature_range_from_state( + state: State, unit: str, default_min: float, default_max: float +) -> tuple[float, float]: + """Return the HomeKit min and max temperature range for a climate state. + + Attribute values are in the entity's unit and converted to Celsius; the + defaults are already Celsius and used as-is. The minimum is clamped to zero + because the Home app crashes on negative bounds. + """ + if (min_temp := state.attributes.get(ATTR_MIN_TEMP)) is not None: + min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 + else: + min_temp = default_min + + if (max_temp := state.attributes.get(ATTR_MAX_TEMP)) is not None: + max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2 + else: + max_temp = default_max + + # Handle a reversed temperature range + min_temp, max_temp = get_min_max(min_temp, max_temp) + + min_temp = max(min_temp, 0) + max_temp = max(max_temp, min_temp) + + return min_temp, max_temp + + +def temperature_attribute_to_homekit(state: State, key: str, unit: str) -> float | None: + """Return a numeric temperature attribute converted to the HomeKit unit.""" + value = state.attributes.get(key) + if isinstance(value, (int, float)): + return temperature_to_homekit(value, unit) + return None + + +def resolve_target_temp_range( + current_high: float, + current_low: float, + new_high: float | None, + new_low: float | None, + min_temp: float, + max_temp: float, +) -> tuple[float, float]: + """Return an ordered (high, low) target range within the temperature bounds. + + The unchanged side keeps its current value and a deadband is enforced so + the range is never inverted. + """ + high = current_high + low = current_low + deadband_enforced = False + if new_high is not None: + high = new_high + if high < low: + low = high - HEAT_COOL_DEADBAND + deadband_enforced = True + if new_low is not None: + low = new_low + if low > high: + high = low + HEAT_COOL_DEADBAND + deadband_enforced = True + high = min(high, max_temp) + low = max(low, min_temp) + # Clamping a deadband-adjusted setpoint to a bound can erase the gap it just + # enforced; restore it by moving the setpoint that is not pinned to the bound. + if deadband_enforced and high - low < HEAT_COOL_DEADBAND: + if high >= max_temp: + low = max(min_temp, high - HEAT_COOL_DEADBAND) + else: + high = min(max_temp, low + HEAT_COOL_DEADBAND) + return high, low diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 95d2da4d942d..8c9e507b590f 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -9,7 +9,6 @@ from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, - ATTR_FAN_MODES, ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, @@ -18,32 +17,18 @@ from homeassistant.components.climate import ( ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, - ATTR_SWING_MODE, - ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_HUMIDITY, - DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, - DEFAULT_MIN_TEMP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_MIDDLE, FAN_OFF, FAN_ON, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, - SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_ON, - SWING_VERTICAL, ClimateEntityFeature, HVACAction, HVACMode, @@ -70,12 +55,14 @@ from homeassistant.const import ( ) from homeassistant.core import State, callback from homeassistant.util.enum import try_parse_enum -from homeassistant.util.percentage import ( - ordered_list_item_to_percentage, - percentage_to_ordered_list_item, -) +from homeassistant.util.percentage import percentage_to_ordered_list_item from .accessories import TYPES, HomeAccessory +from .climate_base import HomeKitClimateAccessory +from .climate_util import ( + get_temperature_range_from_state, + temperature_attribute_to_homekit, +) from .const import ( CHAR_ACTIVE, CHAR_COOLING_THRESHOLD_TEMPERATURE, @@ -99,7 +86,7 @@ from .const import ( SERV_FANV2, SERV_THERMOSTAT, ) -from .util import get_min_max, temperature_to_homekit, temperature_to_states +from .util import get_min_max, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -132,11 +119,6 @@ HC_HEAT_COOL_PREFER_COOL = [ HC_HEAT_COOL_OFF, ] -ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH] -PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS) -SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL] -PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER) - HC_MIN_TEMP = 10 HC_MAX_TEMP = 38 @@ -178,8 +160,6 @@ HC_HASS_TO_HOMEKIT_FAN_STATE = { HVACAction.DEFROSTING: FAN_STATE_IDLE, } -HEAT_COOL_DEADBAND = 5 - def _hk_hvac_mode_from_state(state: State) -> int | None: """Return the equivalent HomeKit HVAC mode for a given state.""" @@ -194,25 +174,17 @@ def _hk_hvac_mode_from_state(state: State) -> int | None: @TYPES.register("Thermostat") -class Thermostat(HomeAccessory): +class Thermostat(HomeKitClimateAccessory): """Generate a Thermostat accessory for a climate.""" def __init__(self, *args: Any) -> None: """Initialize a Thermostat accessory object.""" - super().__init__(*args, category=CATEGORY_THERMOSTAT) - self._unit = self.hass.config.units.temperature_unit + super().__init__(*args) state = self.hass.states.get(self.entity_id) assert state hc_min_temp, hc_max_temp = self.get_temperature_range(state) - self._reload_on_change_attrs.extend( - ( - ATTR_MIN_HUMIDITY, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, - ATTR_FAN_MODES, - ATTR_HVAC_MODES, - ) - ) + # The common climate reload attributes are added by the base class. + self._reload_on_change_attrs.append(ATTR_MIN_HUMIDITY) # Add additional characteristics if auto mode is supported self.chars: list[str] = [] @@ -248,22 +220,14 @@ class Thermostat(HomeAccessory): ) self._configure_hvac_modes(state) - # Must set the value first as setting - # valid_values happens before setting - # the value and if 0 is not a valid - # value this will throw - self.char_target_heat_cool = serv_thermostat.configure_char( - CHAR_TARGET_HEATING_COOLING, value=list(self.hc_homekit_to_hass)[0] + self.char_target_heat_cool = self._configure_target_mode_char( + serv_thermostat, + CHAR_TARGET_HEATING_COOLING, + list(self.hc_homekit_to_hass)[0], + self.hc_hass_to_homekit, ) - self.char_target_heat_cool.override_properties( - valid_values=self.hc_hass_to_homekit - ) - self.char_target_heat_cool.allow_invalid_client_values = True - # Current and target temperature characteristics - self.char_current_temp = serv_thermostat.configure_char( - CHAR_CURRENT_TEMPERATURE, value=21.0 - ) + self._configure_current_temperature_char(serv_thermostat) self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, @@ -318,36 +282,16 @@ class Thermostat(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=50 ) - fan_modes: dict[str, str] = {} - self.ordered_fan_speeds: list[str] = [] + # Fan/swing modes are detected in the base class. + if self.ordered_fan_speeds: + self.fan_chars.append(CHAR_ROTATION_SPEED) - if features & ClimateEntityFeature.FAN_MODE: - fan_modes = { - fan_mode.lower(): fan_mode - for fan_mode in attributes.get(ATTR_FAN_MODES) or [] - } - if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes): - self.ordered_fan_speeds = [ - speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes - ] - self.fan_chars.append(CHAR_ROTATION_SPEED) - - if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds): + if FAN_AUTO in self.fan_modes and ( + FAN_ON in self.fan_modes or self.ordered_fan_speeds + ): self.fan_chars.append(CHAR_TARGET_FAN_STATE) - self.fan_modes = fan_modes - if ( - features & ClimateEntityFeature.SWING_MODE - and (swing_modes := attributes.get(ATTR_SWING_MODES)) - and PRE_DEFINED_SWING_MODES.intersection(swing_modes) - ): - self.swing_on_mode = next( - iter( - swing_mode - for swing_mode in SWING_MODE_PREFERRED_ORDER - if swing_mode in swing_modes - ) - ) + if self.swing_on_mode: self.fan_chars.append(CHAR_SWING_MODE) if self.fan_chars: @@ -362,7 +306,7 @@ class Thermostat(HomeAccessory): self.char_swing = serv_fan.configure_char( CHAR_SWING_MODE, value=0, - setter_callback=self._set_fan_swing_mode, + setter_callback=self._set_swing_mode, ) self.char_swing.display_name = "Swing Mode" if CHAR_ROTATION_SPEED in self.fan_chars: @@ -391,19 +335,6 @@ class Thermostat(HomeAccessory): serv_thermostat.setter_callback = self._set_chars - def _set_fan_swing_mode(self, swing_on: int) -> None: - _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) - mode = self.swing_on_mode if swing_on else SWING_OFF - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, params) - - def _set_fan_speed(self, speed: int) -> None: - _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) - speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, speed - 1) - mode = self.fan_modes[speed_key] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) - def _get_on_mode(self) -> str: if self.ordered_fan_speeds: speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) @@ -429,12 +360,6 @@ class Thermostat(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) - def _temperature_to_homekit(self, temp: float) -> float: - return temperature_to_homekit(temp, self._unit) - - def _temperature_to_states(self, temp: float) -> float: - return temperature_to_states(temp, self._unit) - def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] @@ -458,7 +383,9 @@ class Thermostat(HomeAccessory): # siri will always send HC_HEAT_COOL_AUTO in this case # and hope for the best. hc_target_temp = char_values.get(CHAR_TARGET_TEMPERATURE) - hc_current_temp = _get_current_temperature(state, self._unit) + hc_current_temp = temperature_attribute_to_homekit( + state, ATTR_CURRENT_TEMPERATURE, self._unit + ) hc_fallback_order = HC_HEAT_COOL_PREFER_HEAT if ( hc_target_temp is not None @@ -531,38 +458,20 @@ class Thermostat(HomeAccessory): assert self.char_cooling_thresh_temp assert self.char_heating_thresh_temp service = SERVICE_SET_TEMPERATURE_THERMOSTAT - high = self.char_cooling_thresh_temp.value - low = self.char_heating_thresh_temp.value - min_temp, max_temp = self.get_temperature_range(state) - if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values: - events.append( - f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to" - f" {char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]}°C" - ) - high = char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] - # If the device doesn't support TARGET_TEMPATURE - # this can happen - if high < low: - low = high - HEAT_COOL_DEADBAND - if CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values: - events.append( - f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to" - f" {char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]}°C" - ) - low = char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] - # If the device doesn't support TARGET_TEMPATURE - # this can happen - if low > high: - high = low + HEAT_COOL_DEADBAND - - high = min(high, max_temp) - low = max(low, min_temp) - + new_high = char_values.get(CHAR_COOLING_THRESHOLD_TEMPERATURE) + new_low = char_values.get(CHAR_HEATING_THRESHOLD_TEMPERATURE) + if new_high is not None: + events.append(f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to {new_high}°C") + if new_low is not None: + events.append(f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to {new_low}°C") + # A device without TARGET_TEMPERATURE can send an inverted pair. params.update( - { - ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high), - ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low), - } + self._dual_setpoint_params( + self.char_cooling_thresh_temp, + self.char_heating_thresh_temp, + new_high, + new_low, + ) ) if service: @@ -604,15 +513,6 @@ class Thermostat(HomeAccessory): } self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()} - def get_temperature_range(self, state: State) -> tuple[float, float]: - """Return min and max temperature range.""" - return _get_temperature_range_from_state( - state, - self._unit, - DEFAULT_MIN_TEMP, - DEFAULT_MAX_TEMP, - ) - def set_target_humidity(self, value: float) -> None: """Set target humidity to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) @@ -648,10 +548,7 @@ class Thermostat(HomeAccessory): HC_HASS_TO_HOMEKIT_ACTION.get(hvac_action, HC_HEAT_COOL_OFF) ) - # Update current temperature - current_temp = _get_current_temperature(new_state, self._unit) - if current_temp is not None: - self.char_current_temp.set_value(current_temp) + self._update_current_temperature_char(new_state) # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: @@ -667,22 +564,20 @@ class Thermostat(HomeAccessory): if isinstance(target_humdity, (int, float)): self.char_target_humidity.set_value(target_humdity) - # Update cooling threshold temperature if characteristic exists + # Update threshold temperatures if the characteristics exist if self.char_cooling_thresh_temp: - cooling_thresh = attributes.get(ATTR_TARGET_TEMP_HIGH) - if isinstance(cooling_thresh, (int, float)): - cooling_thresh = self._temperature_to_homekit(cooling_thresh) - self.char_cooling_thresh_temp.set_value(cooling_thresh) - - # Update heating threshold temperature if characteristic exists + self._update_temperature_char( + self.char_cooling_thresh_temp, new_state, ATTR_TARGET_TEMP_HIGH + ) if self.char_heating_thresh_temp: - heating_thresh = attributes.get(ATTR_TARGET_TEMP_LOW) - if isinstance(heating_thresh, (int, float)): - heating_thresh = self._temperature_to_homekit(heating_thresh) - self.char_heating_thresh_temp.set_value(heating_thresh) + self._update_temperature_char( + self.char_heating_thresh_temp, new_state, ATTR_TARGET_TEMP_LOW + ) # Update target temperature - target_temp = _get_target_temperature(new_state, self._unit) + target_temp = temperature_attribute_to_homekit( + new_state, ATTR_TEMPERATURE, self._unit + ) if ( target_temp is None and features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -714,22 +609,11 @@ class Thermostat(HomeAccessory): """Update state without rechecking the device features.""" attributes = new_state.attributes - if CHAR_SWING_MODE in self.fan_chars and ( - swing_mode := attributes.get(ATTR_SWING_MODE) - ): - swing = 1 if swing_mode in PRE_DEFINED_SWING_MODES else 0 - self.char_swing.set_value(swing) + self._update_swing_char(attributes) + self._update_fan_speed_char(attributes) fan_mode = attributes.get(ATTR_FAN_MODE) fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None - if ( - CHAR_ROTATION_SPEED in self.fan_chars - and fan_mode_lower in self.ordered_fan_speeds - ): - self.char_speed.set_value( - ordered_list_item_to_percentage(self.ordered_fan_speeds, fan_mode_lower) - ) - if CHAR_TARGET_FAN_STATE in self.fan_chars: self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0) @@ -811,7 +695,7 @@ class WaterHeater(HomeAccessory): def get_temperature_range(self, state: State) -> tuple[float, float]: """Return min and max temperature range.""" - return _get_temperature_range_from_state( + return get_temperature_range_from_state( state, self._unit, DEFAULT_MIN_TEMP_WATER_HEATER, @@ -881,11 +765,15 @@ class WaterHeater(HomeAccessory): def async_update_state(self, new_state: State) -> None: """Update water_heater state after state change.""" # Update current and target temperature - target_temperature = _get_target_temperature(new_state, self._unit) + target_temperature = temperature_attribute_to_homekit( + new_state, ATTR_TEMPERATURE, self._unit + ) if target_temperature is not None: self.char_target_temp.set_value(target_temperature) - current_temperature = _get_current_temperature(new_state, self._unit) + current_temperature = temperature_attribute_to_homekit( + new_state, ATTR_CURRENT_TEMPERATURE, self._unit + ) if current_temperature is not None: self.char_current_temp.set_value(current_temperature) @@ -902,45 +790,3 @@ class WaterHeater(HomeAccessory): else: self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) self.char_current_heat_cool.set_value(HC_HEAT_COOL_HEAT) - - -def _get_temperature_range_from_state( - state: State, unit: str, default_min: float, default_max: float -) -> tuple[float, float]: - """Calculate the temperature range from a state.""" - if min_temp := state.attributes.get(ATTR_MIN_TEMP): - min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 - else: - min_temp = default_min - - if max_temp := state.attributes.get(ATTR_MAX_TEMP): - max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2 - else: - max_temp = default_max - - # Handle reversed temperature range - min_temp, max_temp = get_min_max(min_temp, max_temp) - - # Homekit only supports 10-38, overwriting - # the max to appears to work, but less than 0 causes - # a crash on the home app - min_temp = max(min_temp, 0) - max_temp = max(max_temp, min_temp) - - return min_temp, max_temp - - -def _get_target_temperature(state: State, unit: str) -> float | None: - """Calculate the target temperature from a state.""" - target_temp = state.attributes.get(ATTR_TEMPERATURE) - if isinstance(target_temp, (int, float)): - return temperature_to_homekit(target_temp, unit) - return None - - -def _get_current_temperature(state: State, unit: str) -> float | None: - """Calculate the current temperature from a state.""" - current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) - if isinstance(current_temp, (int, float)): - return temperature_to_homekit(current_temp, unit) - return None diff --git a/tests/components/homekit/test_climate_util.py b/tests/components/homekit/test_climate_util.py new file mode 100644 index 000000000000..974c49062160 --- /dev/null +++ b/tests/components/homekit/test_climate_util.py @@ -0,0 +1,94 @@ +"""Test the HomeKit climate helper functions.""" + +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODES, +) +from homeassistant.components.homekit.climate_util import ( + HEAT_COOL_DEADBAND, + get_fan_modes_and_speeds, + get_swing_on_mode, + get_temperature_range_from_state, + resolve_target_temp_range, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import State + + +@pytest.mark.parametrize( + ("current_high", "current_low", "new_high", "new_low", "expected"), + [ + # Ordered pair within bounds is returned unchanged. + (24.0, 20.0, 25.0, None, (25.0, 20.0)), + (24.0, 20.0, None, 21.0, (24.0, 21.0)), + # A narrow but ordered band is preserved; the deadband is not forced. + (24.0, 20.0, None, 23.0, (24.0, 23.0)), + # New high crossing below the low enforces the deadband. + (24.0, 20.0, 18.0, None, (18.0, 18.0 - HEAT_COOL_DEADBAND)), + # New low crossing above the high enforces the deadband. + (20.0, 16.0, None, 22.0, (22.0 + HEAT_COOL_DEADBAND, 22.0)), + # Low dragged to the max: the deadband survives the clamp by moving high. + (20.0, 18.0, None, 30.0, (30.0, 30.0 - HEAT_COOL_DEADBAND)), + # High dragged to the min: the deadband survives by moving high up. + (24.0, 20.0, 7.0, None, (7.0 + HEAT_COOL_DEADBAND, 7.0)), + ], +) +def test_resolve_target_temp_range( + current_high: float, + current_low: float, + new_high: float | None, + new_low: float | None, + expected: tuple[float, float], +) -> None: + """Test the range resolver keeps an ordered, in-bounds pair with a deadband.""" + assert ( + resolve_target_temp_range( + current_high, current_low, new_high, new_low, 7.0, 30.0 + ) + == expected + ) + + +@pytest.mark.parametrize( + ("attrs", "expected"), + [ + # A reported bound of exactly 0 is honored, not treated as missing. + ({ATTR_MIN_TEMP: 0, ATTR_MAX_TEMP: 25}, (0.0, 25.0)), + # A missing minimum falls back to the default. + ({ATTR_MAX_TEMP: 25}, (7.0, 25.0)), + # A missing maximum falls back to the default. + ({ATTR_MIN_TEMP: 10}, (10.0, 35.0)), + # Both missing use both defaults. + ({}, (7.0, 35.0)), + ], +) +def test_get_temperature_range_from_state( + attrs: dict[str, float], expected: tuple[float, float] +) -> None: + """Test reported bounds are honored, including an explicit 0, else defaults.""" + state = State("climate.test", "cool", attrs) + assert ( + get_temperature_range_from_state(state, UnitOfTemperature.CELSIUS, 7.0, 35.0) + == expected + ) + + +def test_get_fan_modes_and_speeds_ignores_non_string() -> None: + """Test non-string fan modes are ignored rather than raising.""" + fan_modes, speeds = get_fan_modes_and_speeds( + {ATTR_FAN_MODES: ["low", None, "high", 3]} + ) + assert fan_modes == {"low": "low", "high": "high"} + assert speeds == ["low", "high"] + + +def test_get_swing_on_mode_none_when_no_swing_modes() -> None: + """Test the swing helper returns None when the entity has no swing modes.""" + assert get_swing_on_mode({}) is None + assert get_swing_on_mode({ATTR_SWING_MODES: []}) is None + assert get_swing_on_mode({ATTR_SWING_MODES: ["custom"]}) is None + assert get_swing_on_mode({ATTR_SWING_MODES: ["off", "vertical"]}) == "vertical" diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e8e4111d2ce7..66fa2c5763da 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -2968,3 +2968,35 @@ async def test_thermostat_with_capitalized_fan_modes( assert len(call_set_fan_mode) == 2 assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "Low" + + +async def test_climate_base_fan_swing_guards(hass: HomeAssistant, hk_driver) -> None: + """Test the shared fan and swing setters no-op without predefined modes.""" + entity_id = "climate.test" + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + }, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # No predefined fan speeds, so a fan speed write is ignored. + acc._set_fan_speed(50) + # No swing mode, so a swing write is ignored. + acc._set_swing_mode(1) + await hass.async_block_till_done() + + assert len(call_set_fan_mode) == 0 + assert len(call_set_swing_mode) == 0 From e3cdee84f59f4da340aab7199a4e446809591f1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jul 2026 01:55:07 -0500 Subject: [PATCH 217/707] Fix ESPHome UTF-8 unique id collisions (#174814) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- homeassistant/components/esphome/entity.py | 8 +- .../components/esphome/entry_data.py | 38 ++++---- homeassistant/components/esphome/manager.py | 15 +-- tests/components/esphome/test_entity.py | 92 ++++++++++++++----- tests/components/esphome/test_entry_data.py | 65 +++++++++++-- tests/components/esphome/test_repairs.py | 12 ++- tests/components/esphome/test_sensor.py | 6 +- 7 files changed, 170 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index eb6f2772d2cb..2d5ae2161d13 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, + build_device_unique_id, ) import voluptuous as vol @@ -31,12 +32,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN # Import config flow so that it's added to the registry -from .entry_data import ( - DeviceEntityKey, - ESPHomeConfigEntry, - RuntimeEntryData, - build_device_unique_id, -) +from .entry_data import DeviceEntityKey, ESPHomeConfigEntry, RuntimeEntryData from .enum_mapper import EsphomeEnumMapper _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 7dd004c2af83..3003728406dc 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -45,7 +45,7 @@ from aioesphomeapi import ( UserService, ValveInfo, WaterHeaterInfo, - build_unique_id, + build_device_unique_id, ) from aioesphomeapi.model import ButtonInfo from bleak_esphome.backend.device import ESPHomeBluetoothDevice @@ -103,22 +103,6 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { } -def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str: - """Build unique ID for entity, appending @device_id if it belongs to a sub-device. - - This wrapper around build_unique_id ensures that entities belonging to sub-devices - have their device_id appended to the unique_id to handle proper migration when - entities move between devices. - """ - base_unique_id = build_unique_id(mac, entity_info) - - # If entity belongs to a sub-device, append @device_id - if entity_info.device_id: - return f"{base_unique_id}@{entity_info.device_id}" - - return base_unique_id - - class StoreData(TypedDict, total=False): """ESPHome storage data.""" @@ -310,11 +294,31 @@ class RuntimeEntryData: infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict( list ) + ent_reg = er.async_get(hass) + registry_get_entity = ent_reg.async_get_entity_id for info in infos: info_type = type(info) if platform := info_types_to_platform.get(info_type): needed_platforms.add(platform) infos_by_type[info_type].append(info) + # Migrate legacy unique ids to the version 3 format that fixes + # UTF-8 collisions. Skip when a version 3 id already exists so a + # downgrade then upgrade keeps the original entity. When two + # legacy ids collided (the bug this fixes) only one registry + # entry exists for it, so the first iterated info claims it and + # the rest get fresh version 3 ids. + old_unique_id = build_device_unique_id(mac, info, version=1) + new_unique_id = build_device_unique_id(mac, info, version=3) + if ( + old_unique_id != new_unique_id + and ( + old_entry := registry_get_entity( + platform, DOMAIN, old_unique_id + ) + ) + and not registry_get_entity(platform, DOMAIN, new_unique_id) + ): + ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) else: _LOGGER.warning( "Entity type %s is not supported in this version of Home Assistant", diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 2a3c44357d6d..a1428ddc702e 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1417,13 +1417,14 @@ async def async_replace_device( upper_mac = new_mac.upper() old_upper_mac = old_mac.upper() for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id): - # -- - old_unique_id = entity.unique_id.split("-") - new_unique_id = "-".join([upper_mac, *old_unique_id[1:]]) - if entity.unique_id != new_unique_id and entity.unique_id.startswith( - old_upper_mac - ): - ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + # The mac is the leading segment of the unique id in every format, + # so swap the prefix without parsing the rest. + if entity.unique_id.startswith(old_upper_mac): + new_unique_id = upper_mac + entity.unique_id[len(old_upper_mac) :] + if new_unique_id != entity.unique_id: + ent_reg.async_update_entity( + entity.entity_id, new_unique_id=new_unique_id + ) domain_data = DomainData.get(hass) store = domain_data.get_or_create_store(hass, entry) diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 36bf86366220..c64d8955631f 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -607,7 +607,7 @@ async def test_entity_id_preserved_on_upgrade_when_in_storage( ent_reg_entry = entity_registry.async_get_or_create( Platform.BINARY_SENSOR, DOMAIN, - "11:22:33:44:55:AA-binary_sensor-my", + "11:22:33:44:55:AA/0/binary_sensor/my", ) entity_registry.async_update_entity( ent_reg_entry.entity_id, @@ -1161,6 +1161,58 @@ async def test_entity_id_with_empty_sub_device_name( assert hass.states.get("binary_sensor.main_device_sensor") is not None +async def test_legacy_unique_id_migrated_to_v3_sub_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test a legacy sub-device unique id is migrated to the version 3 format.""" + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + ] + device_info = {"name": "test", "devices": sub_devices} + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + device_id=22222222, + ), + ] + states = [BinarySensorState(key=1, state=True, missing_state=False)] + + # Seed a registry entry in the legacy format with the @device_id suffix + legacy_entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-temperature@22222222", + suggested_object_id="kitchen_controller_temperature", + ) + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + entity_entry = entity_registry.async_get(legacy_entry.entity_id) + assert entity_entry is not None + # The legacy id is renamed to version 3, keeping the same entity + assert ( + entity_entry.unique_id == "11:22:33:44:55:AA/22222222/binary_sensor/Temperature" + ) + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-temperature@22222222", + ) + is None + ) + + async def test_unique_id_migration_when_entity_moves_between_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -1204,8 +1256,8 @@ async def test_unique_id_migration_when_entity_moves_between_devices( entity_entry = entity_registry.async_get("binary_sensor.test_temperature") assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Initial unique_id should not have @device_id suffix since it's on main device - assert "@" not in initial_unique_id + # Main device entities use device_id 0 in the unique id + assert "/0/" in initial_unique_id # Add sub-device to device info sub_devices = [ @@ -1260,8 +1312,8 @@ async def test_unique_id_migration_when_entity_moves_between_devices( # Wait for entity to be updated await hass.async_block_till_done() - # The entity_id doesn't change when moving between devices - # Only the unique_id gets updated with @device_id suffix + # The entity_id doesn't change when moving between devices, + # only the device segment of the unique_id changes state = hass.states.get("binary_sensor.test_temperature") assert state is not None @@ -1269,9 +1321,8 @@ async def test_unique_id_migration_when_entity_moves_between_devices( entity_entry = entity_registry.async_get("binary_sensor.test_temperature") assert entity_entry is not None - # Unique ID should have been migrated to include @device_id - # This is done by our build_device_unique_id wrapper - expected_unique_id = f"{initial_unique_id}@22222222" + # Unique ID device segment should now be the sub-device id + expected_unique_id = initial_unique_id.replace("/0/", "/22222222/") assert entity_entry.unique_id == expected_unique_id # Entity should now be associated with the sub-device @@ -1331,8 +1382,8 @@ async def test_unique_id_migration_sub_device_to_main_device( ) assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Initial unique_id should have @device_id suffix since it's on sub-device - assert "@22222222" in initial_unique_id + # Sub-device entities carry the sub-device id in the unique id + assert "/22222222/" in initial_unique_id # Update entity info - move to main device new_entity_info = [ @@ -1365,8 +1416,8 @@ async def test_unique_id_migration_sub_device_to_main_device( ) assert entity_entry is not None - # Unique ID should have been migrated to remove @device_id suffix - expected_unique_id = initial_unique_id.replace("@22222222", "") + # Unique ID device segment should now be the main device id 0 + expected_unique_id = initial_unique_id.replace("/22222222/", "/0/") assert entity_entry.unique_id == expected_unique_id # Entity should now be associated with the main device @@ -1427,8 +1478,8 @@ async def test_unique_id_migration_between_sub_devices( ) assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Initial unique_id should have @22222222 suffix - assert "@22222222" in initial_unique_id + # Sub-device entities carry the sub-device id in the unique id + assert "/22222222/" in initial_unique_id # Update entity info - move to second sub-device new_entity_info = [ @@ -1461,8 +1512,8 @@ async def test_unique_id_migration_between_sub_devices( ) assert entity_entry is not None - # Unique ID should have been migrated from @22222222 to @33333333 - expected_unique_id = initial_unique_id.replace("@22222222", "@33333333") + # Unique ID device segment should have moved from 22222222 to 33333333 + expected_unique_id = initial_unique_id.replace("/22222222/", "/33333333/") assert entity_entry.unique_id == expected_unique_id # Entity should now be associated with the second sub-device @@ -1524,8 +1575,8 @@ async def test_entity_device_id_rename_in_yaml( entity_entry = entity_registry.async_get("binary_sensor.old_device_sensor") assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Should have @11111111 suffix - assert "@11111111" in initial_unique_id + # Sub-device entities carry the sub-device id in the unique id + assert "/11111111/" in initial_unique_id # Simulate user renaming device_id in YAML config # The device_id hash changes from 11111111 to 99999999 @@ -1587,9 +1638,8 @@ async def test_entity_device_id_rename_in_yaml( entity_entry = entity_registry.async_get("binary_sensor.renamed_device_sensor") assert entity_entry is not None - # Unique ID should have the new device_id - base_unique_id = initial_unique_id.replace("@11111111", "") - expected_unique_id = f"{base_unique_id}@99999999" + # Unique ID device segment should have the new device_id + expected_unique_id = initial_unique_id.replace("/11111111/", "/99999999/") assert entity_entry.unique_id == expected_unique_id # Entity should be associated with the new device diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index e06c77a2824d..4d63c764ffb3 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -21,6 +21,53 @@ from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from .conftest import MockGenericDeviceEntryType +async def test_migrate_entity_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a legacy unique id is migrated to the version 3 format.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "11:22:33:44:55:AA-sensor-mysensor", + suggested_object_id="my_sensor", + disabled_by=None, + ) + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, + icon="mdi:leaf", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.my_sensor") + assert state is not None + assert state.state == "50" + entry = entity_registry.async_get("sensor.my_sensor") + assert entry is not None + # The legacy unique id should have been renamed to the version 3 format, + # keeping the entity (and its entity_id) instead of creating a new one + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "11:22:33:44:55:AA-sensor-mysensor" + ) + is None + ) + + async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -28,18 +75,20 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test unique id migration prefers the original entity on downgrade upgrade.""" + # The original entity, already in the version 3 format entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, - "my_sensor", - suggested_object_id="old_sensor", + "11:22:33:44:55:AA/0/sensor/my sensor", + suggested_object_id="new_sensor", disabled_by=None, ) + # A duplicate left behind in the legacy format by a downgrade entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "11:22:33:44:55:AA-sensor-mysensor", - suggested_object_id="new_sensor", + suggested_object_id="old_sensor", disabled_by=None, ) entity_info = [ @@ -64,17 +113,17 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( assert state.state == "50" entry = entity_registry.async_get("sensor.new_sensor") assert entry is not None - # Confirm we did not touch the entity that was created + # Confirm we did not touch the legacy entity that was created # on downgrade so when they upgrade again they can delete the # entity that was only created on downgrade and they keep # the original one. assert ( - entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, "my_sensor") + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "11:22:33:44:55:AA-sensor-mysensor" + ) is not None ) - # Note that ESPHome includes the EntityInfo type in the unique id - # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" async def test_discover_zwave() -> None: diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index 5a6090234a48..975f0fb31eff 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -139,13 +139,15 @@ async def test_device_conflict_migration( ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry - assert ent_reg_entry.unique_id == "11:22:33:44:55:AA-binary_sensor-mybinary_sensor" + assert ( + ent_reg_entry.unique_id == "11:22:33:44:55:AA/0/binary_sensor/my binary_sensor" + ) entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) assert entries is not None for entry in entries: - assert entry.unique_id.startswith("11:22:33:44:55:AA-") + assert entry.unique_id.startswith("11:22:33:44:55:AA/") disconnect_done = hass.loop.create_future() async def async_disconnect(*args, **kwargs) -> None: @@ -201,14 +203,16 @@ async def test_device_conflict_migration( assert mock_config_entry.unique_id == "11:22:33:44:55:ab" ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry - assert ent_reg_entry.unique_id == "11:22:33:44:55:AB-binary_sensor-mybinary_sensor" + assert ( + ent_reg_entry.unique_id == "11:22:33:44:55:AB/0/binary_sensor/my binary_sensor" + ) entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) assert entries is not None for entry in entries: - assert entry.unique_id.startswith("11:22:33:44:55:AB-") + assert entry.unique_id.startswith("11:22:33:44:55:AB/") dev_entry = device_registry.async_get_device( identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:ab")} diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 926d24a7869b..adea7007c2c8 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -129,7 +129,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -168,7 +168,7 @@ async def test_generic_numeric_sensor_state_class_measurement( assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" assert entry.entity_category is None @@ -205,7 +205,7 @@ async def test_generic_numeric_sensor_state_class_measurement_angle( assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" assert entry.entity_category is None From a8a478b49ccfc0fc43a98b53193231b82434710a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 08:56:58 +0200 Subject: [PATCH 218/707] Stop UniFi from piling up thousands of stale client devices (#175856) Co-authored-by: Timothy <6560631+TimoPtr@users.noreply.github.com> --- homeassistant/components/unifi/const.py | 7 ++ .../components/unifi/hub/entity_loader.py | 83 +++++++++++++++---- homeassistant/components/unifi/sensor.py | 10 ++- tests/components/unifi/test_device_tracker.py | 79 +++++++++++++++++- tests/components/unifi/test_sensor.py | 31 ++++++- 5 files changed, 192 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index f3d088b42b1c..7f39d1207920 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -1,5 +1,6 @@ """Constants for the UniFi Network integration.""" +from datetime import timedelta import logging from aiounifi.models.device import DeviceState @@ -9,6 +10,12 @@ from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) DOMAIN = "unifi" +# The UniFi controller keeps a record of every client it has ever seen. On busy +# or guest networks that is easily tens of thousands of drive-by devices. +# Only inactive clients seen within this window are restored on startup, older +# ones are pruned together with their device so the registry stops growing. +CLIENT_RESTORE_MAX_AGE = timedelta(days=30) + PLATFORMS = [ Platform.BUTTON, Platform.DEVICE_TRACKER, diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 067b8a44865f..14a1375a73b2 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -6,19 +6,21 @@ Make sure expected clients are available for platforms. import asyncio from collections.abc import Callable, Coroutine, Sequence -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial from typing import TYPE_CHECKING, Any from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent +from aiounifi.models.client import Client from homeassistant.const import Platform from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util -from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS +from ..const import CLIENT_RESTORE_MAX_AGE, LOGGER, UNIFI_WIRELESS_CLIENTS from ..coordinator import UnifiDataUpdateCoordinator from ..entity import UnifiEntity, UnifiEntityDescription @@ -102,24 +104,77 @@ class UnifiEntityLoader: @callback def _restore_inactive_clients(self) -> None: - """Restore inactive clients. + """Restore recently seen inactive clients and prune stale ones. - Provide inactive clients to device tracker and switch platform. + The UniFi controller keeps a record of every client it has ever seen. + Only clients seen within the retention window, or explicitly selected + or blocked, are restored. Trackers falling outside that window are + removed together with their device so the registry does not grow + unbounded. """ config = self.hub.config - entity_registry = er.async_get(self.hub.hass) - macs: list[str] = [ - entry.unique_id.split("-", 1)[1] - for entry in er.async_entries_for_config_entry( - entity_registry, config.entry.entry_id - ) - if entry.domain == Platform.DEVICE_TRACKER and "-" in entry.unique_id - ] api = self.hub.api - for mac in config.option_supported_clients + config.option_block_clients + macs: + entity_registry = er.async_get(self.hub.hass) + device_registry = dr.async_get(self.hub.hass) + + now = dt_util.utcnow() + always_restore = set(config.option_supported_clients) + always_restore.update(config.option_block_clients) + + pruned = 0 + for entry in er.async_entries_for_config_entry( + entity_registry, config.entry.entry_id + ): + if entry.domain != Platform.DEVICE_TRACKER or "-" not in entry.unique_id: + continue + + mac = entry.unique_id.split("-", 1)[1] + if mac in api.clients or mac in always_restore: + continue + + # Absent means the controller no longer reports it or the + # clients_all fetch failed this cycle. Never prune on that, a failed + # fetch would wipe every tracker and its device. + if (client := api.clients_all.get(mac)) is None: + continue + + if not self._client_is_stale(client, now): + api.clients.process_raw([dict(client.raw)]) + continue + + self._remove_client(entity_registry, device_registry, entry.entity_id, mac) + pruned += 1 + + if pruned: + LOGGER.debug("Pruned %s stale UniFi client device(s)", pruned) + + for mac in always_restore: if mac not in api.clients and mac in api.clients_all: api.clients.process_raw([dict(api.clients_all[mac].raw)]) + @callback + def _client_is_stale(self, client: Client, now: datetime) -> bool: + """Return if a client has not been seen within the retention window.""" + last_seen = dt_util.utc_from_timestamp(client.last_seen or 0) + return now - last_seen > CLIENT_RESTORE_MAX_AGE + + @callback + def _remove_client( + self, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + entity_id: str, + mac: str, + ) -> None: + """Remove a stale client's tracker entity and its device.""" + entity_registry.async_remove(entity_id) + if device := device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ): + device_registry.async_update_device( + device.id, remove_config_entry_id=self.hub.config.entry.entry_id + ) + @callback def register_platform( self, diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index dae93b0a9c33..cc8e0fc234e5 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -50,6 +50,7 @@ from homeassistant.util import dt as dt_util, slugify from . import UnifiConfigEntry from .const import DEVICE_STATES +from .device_tracker import async_client_allowed_fn from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -106,11 +107,16 @@ def async_client_uptime_value_fn(hub: UnifiHub, client: Client) -> datetime: @callback def async_wired_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: - """Check if client is wired and allowed.""" + """Check if client is wired, tracked and reports a link speed. + + Gate on the tracking options so the sensor (and its client device) is only + created for clients the user actually tracks, instead of every wired client + the controller has ever seen. + """ client = hub.api.clients[obj_id] if not client.is_wired or client.wired_rate_mbps <= 0: return False - return True + return async_client_allowed_fn(hub, obj_id) @callback diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 81e3e4a9338d..8a162fe5eba8 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -25,7 +25,7 @@ from homeassistant.components.unifi.const import ( ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from .conftest import ( @@ -610,6 +610,83 @@ async def test_restoring_client( assert not hass.states.get("device_tracker.not_restored") +@pytest.mark.parametrize("client_payload", [[WIRED_CLIENT_1]]) +@pytest.mark.parametrize( + "clients_all_payload", + [ + [ + { + "hostname": "recent", + "is_wired": True, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:05", + }, + { + "hostname": "stale", + "is_wired": True, + "last_seen": 1562600145, # 2019, well beyond the retention window + "mac": "00:00:00:00:00:06", + }, + ] + ], +) +async def test_pruning_stale_restored_clients( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + config_entry_factory: ConfigEntryFactoryType, + clients_all_payload: list[dict[str, Any]], +) -> None: + """Restore recently seen inactive clients but prune stale ones and their device.""" + recent, stale = clients_all_payload + # Client with a tracker but absent from clients_all, e.g. a failed fetch + absent_mac = "00:00:00:00:00:07" + + entries: dict[str, er.RegistryEntry] = {} + for mac, hostname in ( + (recent["mac"], "recent"), + (stale["mac"], "stale"), + (absent_mac, "absent"), + ): + entries[mac] = entity_registry.async_get_or_create( + TRACKER_DOMAIN, + DOMAIN, + f"site_id-{mac}", + suggested_object_id=hostname, + config_entry=config_entry, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + + await config_entry_factory() + + # Recently seen client is restored with its tracker and device intact + assert hass.states.get("device_tracker.recent") + assert entity_registry.async_get(entries[recent["mac"]].entity_id) + assert device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, recent["mac"])} + ) + + # Stale client is pruned together with its device + assert not hass.states.get("device_tracker.stale") + assert entity_registry.async_get(entries[stale["mac"]].entity_id) is None + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, stale["mac"])} + ) + is None + ) + + # Client absent from clients_all is left untouched, never pruned on missing data + assert entity_registry.async_get(entries[absent_mac].entity_id) + assert device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, absent_mac)} + ) + + @pytest.mark.parametrize( ("config_entry_options", "counts", "expected"), [ diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 8d2df550be58..85eef64c8da0 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.components.unifi.const import ( CONF_DETECTION_TIME, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + CONF_TRACK_WIRED_CLIENTS, DEFAULT_DETECTION_TIME, DEVICE_STATES, ) @@ -35,7 +36,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util @@ -579,6 +580,34 @@ async def test_wired_client_speed_sensor( assert hass.states.get("sensor.wired_client_link_speed").state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_TRACK_CLIENTS: False, + CONF_TRACK_WIRED_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize("client_payload", [[WIRED_CLIENT]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_wired_client_speed_sensor_not_created_when_untracked( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client_payload: list[dict[str, Any]], +) -> None: + """Verify untracked wired clients create neither a link speed sensor nor a device.""" + assert entity_registry.async_get("sensor.wired_client_link_speed") is None + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])} + ) + is None + ) + + @pytest.mark.parametrize( "config_entry_options", [{CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True}], From aeeb57305b601a73e441432fa3a932bbc106ab1d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 08:58:59 +0200 Subject: [PATCH 219/707] Extract entities from time triggers and time conditions (#175503) --- homeassistant/helpers/condition.py | 15 ++++++++++++++- homeassistant/helpers/trigger.py | 12 ++++++++++++ tests/helpers/test_condition.py | 11 +++++++++++ tests/helpers/test_trigger.py | 17 +++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 7e057921f616..2ff29fdb9898 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -56,7 +56,13 @@ from homeassistant.const import ( WEEKDAYS, EntityStateAttribute, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import ( + HomeAssistant, + State, + callback, + split_entity_id, + valid_entity_id, +) from homeassistant.exceptions import ( ConditionError, ConditionErrorContainer, @@ -2119,6 +2125,13 @@ def async_extract_entities(config: ConfigType | Template) -> set[str]: to_process.extend(config["conditions"]) continue + if condition == "time": + # The before and after options can be a time or an entity id. + for key in (CONF_AFTER, CONF_BEFORE): + if isinstance(value := config.get(key), str) and valid_entity_id(value): + referenced.add(value) + continue + entity_ids = config.get(CONF_ENTITY_ID) if isinstance(entity_ids, str): diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index b993b07bff4b..d34ee24d98d5 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -27,6 +27,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ALIAS, + CONF_AT, CONF_DEVICE_ID, CONF_ENABLED, CONF_ENTITY_ID, @@ -2055,6 +2056,17 @@ def async_extract_entities(trigger_conf: dict) -> list[str]: if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"): return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return] + if trigger_conf[CONF_PLATFORM] == "time": + # Each at time can be a time, an entity id, an entity id with + # an offset, or a template. + entity_ids: list[str] = [] + for at_time in trigger_conf[CONF_AT]: + if isinstance(at_time, str) and valid_entity_id(at_time): + entity_ids.append(at_time) + elif isinstance(at_time, dict) and CONF_ENTITY_ID in at_time: + entity_ids.append(at_time[CONF_ENTITY_ID]) + return entity_ids + if trigger_conf[CONF_PLATFORM] == "calendar": return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]] diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 8dadec50063c..a98c47c30846 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2134,10 +2134,21 @@ async def test_extract_entities(hass: HomeAssistant) -> None: "entity_id": ["sensor.temperature_9", "sensor.temperature_10"], "below": 110, }, + { + "condition": "time", + "after": "input_datetime.start", + "before": "sensor.end", + }, + { + "condition": "time", + "after": "08:00:00", + }, Template("{{ is_state('light.example', 'on') }}", hass), ], } ) == { + "input_datetime.start", + "sensor.end", "sensor.temperature", "sensor.temperature_2", "sensor.temperature_3", diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 3704dbdef4ba..a099ab76a555 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -5896,6 +5896,23 @@ def mock_test_modern_trigger(hass: HomeAssistant) -> None: ["calendar.x"], id="calendar", ), + pytest.param( + {"platform": "time", "at": "05:00:00"}, + [], + id="time-plain", + ), + pytest.param( + { + "platform": "time", + "at": [ + "05:00:00", + "input_datetime.alarm", + {"entity_id": "sensor.next_alarm", "offset": "-00:05:00"}, + ], + }, + ["input_datetime.alarm", "sensor.next_alarm"], + id="time-entities", + ), pytest.param( { "platform": "zone", From 7066f95f0dbe360f6a0d31e5ed168a40274ce110 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 08:59:48 +0200 Subject: [PATCH 220/707] Extract entities and devices from event action data (#175491) --- homeassistant/helpers/script.py | 15 +++++++++++++++ tests/helpers/test_script.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 2653d8132821..7ce72f422295 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -76,6 +76,7 @@ from homeassistant.core import ( State, SupportsResponse, callback, + valid_entity_id, ) from homeassistant.util import slugify from homeassistant.util.async_ import create_eager_task @@ -1709,6 +1710,12 @@ class Script: elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: referenced.add(step[CONF_DEVICE_ID]) + elif action == cv.SCRIPT_ACTION_FIRE_EVENT: + if (event_data := step.get(CONF_EVENT_DATA)) and isinstance( + device_id := event_data.get(ATTR_DEVICE_ID), str + ): + referenced.add(device_id) + elif action == cv.SCRIPT_ACTION_CHOOSE: for choice in step[CONF_CHOOSE]: for cond in choice[CONF_CONDITIONS]: @@ -1764,6 +1771,14 @@ class Script: elif action == cv.SCRIPT_ACTION_ACTIVATE_SCENE: referenced.add(step[CONF_SCENE]) + elif action == cv.SCRIPT_ACTION_FIRE_EVENT: + if ( + (event_data := step.get(CONF_EVENT_DATA)) + and isinstance(entity_id := event_data.get(ATTR_ENTITY_ID), str) + and valid_entity_id(entity_id) + ): + referenced.add(entity_id) + elif action == cv.SCRIPT_ACTION_CHOOSE: for choice in step[CONF_CHOOSE]: for cond in choice[CONF_CONDITIONS]: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index ee739e1367f5..1528c9bd522c 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4734,6 +4734,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: ], }, {"event": "test_event"}, + { + "event": "test_event", + "event_data": {"entity_id": "light.event_data"}, + }, + { + "event": "test_event", + "event_data": {"entity_id": "not-a-valid-entity-id"}, + }, {"delay": "{{ delay_period }}"}, { "if": [], @@ -4811,6 +4819,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.direct_entity_referenced", "light.entity_in_data_template", "light.entity_in_target", + "light.event_data", "light.service_list", "light.service_not_list", "light.if_then", @@ -4946,6 +4955,11 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "domain": "switch", }, }, + {"event": "test_event"}, + { + "event": "test_event", + "event_data": {"device_id": "event-data-device"}, + }, { "wait_for_trigger": { "platform": "state", @@ -4982,6 +4996,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "data-string-id", "data-template-string-id", "default-device-target", + "event-data-device", "script-dev-id", "target-list-id-1", "target-list-id-2", From 72c3f90df4b851c6d527c775db1f3e6de4d6f8a0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 09:00:17 +0200 Subject: [PATCH 221/707] Include referenced labels when searching automations and scripts (#175459) --- homeassistant/components/search/__init__.py | 9 +++++++++ tests/components/search/test_init.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 2dd93008fd4d..e474463d3209 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -220,6 +220,12 @@ class Searcher: automation.blueprint_in_automation(self.hass, automation_entity_id), ) + # Labels referenced in this automation + self._add( + ItemType.LABEL, + automation.labels_in_automation(self.hass, automation_entity_id), + ) + # Floors referenced in this automation self._add( ItemType.FLOOR, @@ -492,6 +498,9 @@ class Searcher: script.blueprint_in_script(self.hass, script_entity_id), ) + # Labels referenced in this script + self._add(ItemType.LABEL, script.labels_in_script(self.hass, script_entity_id)) + # Floors referenced in this script self._add(ItemType.FLOOR, script.floors_in_script(self.hass, script_entity_id)) diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 3352250630cf..bf95372fd2df 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -578,6 +578,9 @@ async def test_search( ItemType.AREA: {kitchen_area.id}, ItemType.FLOOR: {first_floor.floor_id}, } + assert search(ItemType.AUTOMATION, "automation.label") == { + ItemType.LABEL: {label_christmas.label_id}, + } assert search(ItemType.AUTOMATION, "automation.group") == { ItemType.AREA: {bedroom_area.id, living_room_area.id, kitchen_area.id}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id, hue_config_entry.entry_id}, @@ -981,6 +984,9 @@ async def test_search( ItemType.AREA: {kitchen_area.id}, ItemType.FLOOR: {first_floor.floor_id}, } + assert search(ItemType.SCRIPT, "script.label") == { + ItemType.LABEL: {label_other.label_id}, + } assert search(ItemType.SCRIPT, "script.group") == { ItemType.AREA: {bedroom_area.id, living_room_area.id, kitchen_area.id}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id, hue_config_entry.entry_id}, From 23491c80fa69d9b65e8d4eb0a1a12267d544ff07 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 09:03:23 +0200 Subject: [PATCH 222/707] Include config entry groups in groups_with_entity (#175451) --- homeassistant/components/group/__init__.py | 43 +++++++++++++++--- tests/components/group/test_init.py | 53 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 2061ce831cf8..2fafaa192806 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.group import ( expand_entity_ids as _expand_entity_ids, get_entity_ids as _get_entity_ids, + get_group_entities, ) from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.typing import ConfigType @@ -122,14 +123,42 @@ def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: Async friendly. """ - if DOMAIN not in hass.data: - return [] + groups: list[str] = [] - return [ - group.entity_id - for group in hass.data[DATA_COMPONENT].entities - if entity_id in group.tracking - ] + if DOMAIN in hass.data: + groups.extend( + group.entity_id + for group in hass.data[DATA_COMPONENT].entities + if entity_id in group.tracking + ) + + groups.extend( + group_entity_id + for group_entity_id, entity in get_group_entities(hass).items() + if entity.group is not None + and entity_id in entity.group.member_entity_ids + and group_entity_id not in groups + ) + + # Config entry groups whose platform does not (yet) register in + # the group entities registry of the group helper. + entity_registry = er.async_get(hass) + for entry in hass.config_entries.async_entries(DOMAIN): + members = [ + er.async_resolve_entity_id(entity_registry, member) or member + for member in entry.options[CONF_ENTITIES] + ] + if entity_id not in members: + continue + groups.extend( + registry_entry.entity_id + for registry_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if registry_entry.entity_id not in groups + ) + + return groups async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 50fcdeb1f5e4..a04e2ddc55f6 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1999,6 +1999,59 @@ async def test_setup_and_remove_config_entry( assert entity_registry.async_get(f"{group_type}.bed_room") is None +async def test_groups_with_entity(hass: HomeAssistant) -> None: + """Test groups_with_entity finds legacy groups.""" + assert await async_setup_component( + hass, + "group", + {"group": {"living_room": {"entities": ["light.one", "light.two"]}}}, + ) + await hass.async_block_till_done() + + assert group.groups_with_entity(hass, "light.one") == ["group.living_room"] + assert group.groups_with_entity(hass, "light.three") == [] + + +@pytest.mark.parametrize( + ("group_type", "member_state", "extra_options"), + [ + pytest.param("light", "on", {"all": False}, id="light"), + pytest.param("lock", "locked", {}, id="lock"), + ], +) +async def test_groups_with_entity_config_entry( + hass: HomeAssistant, + group_type: str, + member_state: str, + extra_options: dict[str, Any], +) -> None: + """Test groups_with_entity finds config entry groups.""" + members = [f"{group_type}.one", f"{group_type}.two"] + + for member in members: + hass.states.async_set(member, member_state, {}) + + group_config_entry = MockConfigEntry( + data={}, + domain=group.DOMAIN, + options={ + "entities": members, + "group_type": group_type, + "name": "Bed Room", + **extra_options, + }, + title="Bed Room", + ) + group_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(group_config_entry.entry_id) + await hass.async_block_till_done() + + assert group.groups_with_entity(hass, f"{group_type}.one") == [ + f"{group_type}.bed_room" + ] + assert group.groups_with_entity(hass, f"{group_type}.three") == [] + + @pytest.mark.parametrize( ("hide_members", "hidden_by_initial", "hidden_by"), [ From 050065ae70a43466a07bcbb8e28b61b7ad610187 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 09:05:27 +0200 Subject: [PATCH 223/707] Add integration item type support to search (#175450) --- homeassistant/components/search/__init__.py | 13 +++++++++ tests/components/search/test_init.py | 31 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index e474463d3209..5b6111e04b48 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -296,6 +296,19 @@ class Searcher: self._add(ItemType.ENTITY, entity_entry.entity_id) self._async_search_entity(entity_entry.entity_id, entry_point=False) + @callback + def _async_search_integration(self, domain: str) -> None: + """Find results for an integration.""" + for entry in self.hass.config_entries.async_entries(domain): + self._add(ItemType.CONFIG_ENTRY, entry.entry_id) + self._async_search_config_entry(entry.entry_id) + + for entity_id, source in self._entity_sources.items(): + if source["domain"] != domain: + continue + self._add(ItemType.ENTITY, entity_id) + self._async_search_entity(entity_id, entry_point=False) + @callback def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> None: """Find results for a device.""" diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index bf95372fd2df..b36e0611b3b8 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -869,6 +869,37 @@ async def test_search( ItemType.SCRIPT: {"script.group"}, } + assert not search(ItemType.INTEGRATION, "unknown") + assert search(ItemType.INTEGRATION, "wled") == { + ItemType.AREA: {bedroom_area.id, living_room_area.id}, + ItemType.AUTOMATION: {"automation.wled_entity", "automation.wled_device"}, + ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, + ItemType.DEVICE: {wled_device.id}, + ItemType.ENTITY: { + wled_segment_1_entity.entity_id, + wled_segment_2_entity.entity_id, + "light.wled_platform_config_source", + "light.wled_config_entry_source", + }, + ItemType.FLOOR: {first_floor.floor_id, second_floor.floor_id}, + ItemType.GROUP: {"group.wled", "group.wled_hue"}, + ItemType.SCENE: {"scene.scene_wled_seg_1", scene_wled_hue_entity.entity_id}, + ItemType.SCRIPT: {"script.wled"}, + } + assert search(ItemType.INTEGRATION, "hue") == { + ItemType.AREA: {kitchen_area.id}, + ItemType.CONFIG_ENTRY: {hue_config_entry.entry_id}, + ItemType.DEVICE: {hue_device.id}, + ItemType.ENTITY: { + hue_segment_1_entity.entity_id, + hue_segment_2_entity.entity_id, + }, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.GROUP: {"group.hue", "group.wled_hue"}, + ItemType.SCENE: {"scene.scene_hue_seg_1", scene_wled_hue_entity.entity_id}, + ItemType.SCRIPT: {"script.device", "script.hue"}, + } + assert not search(ItemType.LABEL, "unknown") assert search(ItemType.LABEL, label_christmas.label_id) == { ItemType.AREA: {living_room_area.id}, From 7aae637eeb874e54f35698679d7a39b1e3a2f71d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 8 Jul 2026 00:14:12 -0700 Subject: [PATCH 224/707] Implement OAuth2 re-authentication flow for Google Health (#175893) --- .../components/google_health/config_flow.py | 20 +++- .../google_health/quality_scale.yaml | 2 +- .../components/google_health/strings.json | 5 + .../google_health/test_config_flow.py | 99 ++++++++++++++++++- tests/components/google_health/test_init.py | 42 +++++++- 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_health/config_flow.py b/homeassistant/components/google_health/config_flow.py index 0eb1139b4434..25d21a36ac20 100644 --- a/homeassistant/components/google_health/config_flow.py +++ b/homeassistant/components/google_health/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Google Health.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -7,7 +8,7 @@ from google_health_api import GoogleHealthApi from google_health_api.const import HealthApiScope from google_health_api.exceptions import GoogleHealthApiError -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -40,6 +41,20 @@ class OAuth2FlowHandler( "prompt": "consent", } + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + @override async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: scopes = data.get(CONF_TOKEN, {}).get("scope", "").split() @@ -62,6 +77,9 @@ class OAuth2FlowHandler( return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(identity.health_user_id) + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort(reauth_entry, data=data) self._abort_if_unique_id_configured() display_name = None diff --git a/homeassistant/components/google_health/quality_scale.yaml b/homeassistant/components/google_health/quality_scale.yaml index 95d9da564f3c..adec2cbaf41d 100644 --- a/homeassistant/components/google_health/quality_scale.yaml +++ b/homeassistant/components/google_health/quality_scale.yaml @@ -44,7 +44,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json index 08c5257344a8..3921b4deaa84 100644 --- a/homeassistant/components/google_health/strings.json +++ b/homeassistant/components/google_health/strings.json @@ -16,6 +16,7 @@ "oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" }, "create_entry": { @@ -24,6 +25,10 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Google Health integration needs to re-authenticate your account", + "title": "[%key:common::config_flow::title::reauth%]" } } }, diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py index 3410f2f3a269..daae98d21953 100644 --- a/tests/components/google_health/test_config_flow.py +++ b/tests/components/google_health/test_config_flow.py @@ -1,11 +1,12 @@ """Test the Google Health config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from google_health_api.exceptions import GoogleHealthApiError from google_health_api.model import Identity import pytest +from homeassistant import config_entries from homeassistant.components.google_health.const import ( DOMAIN, OAUTH2_AUTHORIZE, @@ -17,9 +18,14 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +API_BASE_URL = "https://health.googleapis.com/v4/users/me" +IDENTITY_URL = f"{API_BASE_URL}/identity" +USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -254,3 +260,94 @@ async def test_config_flow_profile_name_error( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Google Health" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test reauth flow completes successfully.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": "google", + "token": { + "access_token": "old-access-token", + "refresh_token": "old-refresh-token", + "scope": " ".join(OAUTH_SCOPES), + }, + }, + unique_id="mock-health-user-id", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + aioclient_mock.get( + IDENTITY_URL, + json={ + "name": "users/me/identity", + "healthUserId": "mock-health-user-id", + }, + ) + + aioclient_mock.get( + USERINFO_URL, + json={ + "givenName": "Allen", + "name": "Allen Porter", + }, + ) + + with patch( + "homeassistant.components.google_health.async_setup_entry", return_value=True + ) as mock_setup: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + assert config_entry.data["token"]["access_token"] == "new-access-token" + assert config_entry.data["token"]["refresh_token"] == "new-refresh-token" + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/google_health/test_init.py b/tests/components/google_health/test_init.py index ec80e866cf83..ec66763deebd 100644 --- a/tests/components/google_health/test_init.py +++ b/tests/components/google_health/test_init.py @@ -1,21 +1,25 @@ """Tests for Google Health integration lifecycle (init/unloading).""" from collections.abc import Awaitable, Callable +from datetime import timedelta from unittest.mock import AsyncMock, patch from google_health_api.exceptions import ( GoogleHealthApiError, HealthApiForbiddenException, + HealthAuthException, ) import pytest from homeassistant import config_entries +from homeassistant.components.google_health.coordinator import POLLING_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_google_health_client") @@ -64,7 +68,8 @@ async def test_setup_auth_error( assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() - assert len(flows) == 0 + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" @pytest.mark.usefixtures("mock_google_health_client") @@ -81,7 +86,8 @@ async def test_setup_missing_scopes( assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() - assert len(flows) == 0 + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" @pytest.mark.usefixtures("mock_google_health_client") @@ -151,3 +157,33 @@ async def test_setup_oauth_implementation_unavailable( await hass.async_block_till_done() assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_google_health_client") +async def test_runtime_auth_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + mock_google_health_client: AsyncMock, +) -> None: + """Test runtime auth failure triggers a reauth flow.""" + # Setup the integration + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + # Mock an authorization error on subsequent update refresh + mock_google_health_client.steps.today.side_effect = HealthAuthException( + "Token expired" + ) + + # Trigger update by advancing time + async_fire_time_changed( + hass, + dt_util.utcnow() + POLLING_INTERVAL + timedelta(seconds=1), + ) + await hass.async_block_till_done() + + # Verify that the flow was initiated + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" From 4b0159aee8d015d0c21b9e5e74fb88efb4608bea Mon Sep 17 00:00:00 2001 From: Mike N8RAW <7760516+mkmer@users.noreply.github.com> Date: Wed, 8 Jul 2026 03:15:37 -0400 Subject: [PATCH 225/707] Bump AIOSomecomofort to 0.0.37 (#175891) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 79ee0ffc91f6..451202b73a6b 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.35"] + "requirements": ["AIOSomecomfort==0.0.37"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76c35f7274e7..0bf56d423bdb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.35 +AIOSomecomfort==0.0.37 # homeassistant.components.adax Adax-local==0.3.0 From b2ea48d4354305902c297c2c94162badb0a72acc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 09:19:35 +0200 Subject: [PATCH 226/707] Include repeat actions in script reference extraction (#175420) --- homeassistant/helpers/script.py | 27 ++++ tests/helpers/test_script.py | 224 ++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 7ce72f422295..7c4cad41cbe5 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1669,6 +1669,15 @@ class Script: if CONF_ELSE in step: Script._find_referenced_target(target, referenced, step[CONF_ELSE]) + elif action == cv.SCRIPT_ACTION_REPEAT: + for cond in step[CONF_REPEAT].get(CONF_WHILE, []): + referenced |= condition.async_extract_targets(cond, target) + for cond in step[CONF_REPEAT].get(CONF_UNTIL, []): + referenced |= condition.async_extract_targets(cond, target) + Script._find_referenced_target( + target, referenced, step[CONF_REPEAT][CONF_SEQUENCE] + ) + elif action == cv.SCRIPT_ACTION_PARALLEL: for script in step[CONF_PARALLEL]: Script._find_referenced_target( @@ -1731,6 +1740,15 @@ class Script: if CONF_ELSE in step: Script._find_referenced_devices(referenced, step[CONF_ELSE]) + elif action == cv.SCRIPT_ACTION_REPEAT: + for cond in step[CONF_REPEAT].get(CONF_WHILE, []): + referenced |= condition.async_extract_devices(cond) + for cond in step[CONF_REPEAT].get(CONF_UNTIL, []): + referenced |= condition.async_extract_devices(cond) + Script._find_referenced_devices( + referenced, step[CONF_REPEAT][CONF_SEQUENCE] + ) + elif action == cv.SCRIPT_ACTION_PARALLEL: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) @@ -1794,6 +1812,15 @@ class Script: if CONF_ELSE in step: Script._find_referenced_entities(referenced, step[CONF_ELSE]) + elif action == cv.SCRIPT_ACTION_REPEAT: + for cond in step[CONF_REPEAT].get(CONF_WHILE, []): + referenced |= condition.async_extract_entities(cond) + for cond in step[CONF_REPEAT].get(CONF_UNTIL, []): + referenced |= condition.async_extract_entities(cond) + Script._find_referenced_entities( + referenced, step[CONF_REPEAT][CONF_SEQUENCE] + ) + elif action == cv.SCRIPT_ACTION_PARALLEL: for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 1528c9bd522c..4f55abf2d2c7 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4307,6 +4307,45 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "light.is_on", + "target": {"label_id": "label_repeat_while_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "light.is_on", + "target": {"label_id": "label_repeat_until_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4345,6 +4384,11 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_in_data_template", "label_in_target", "label_parallel", + "label_repeat_count_seq", + "label_repeat_until_cond", + "label_repeat_until_seq", + "label_repeat_while_cond", + "label_repeat_while_seq", "label_sequence", "label_service_list_1", "label_service_list_2", @@ -4461,6 +4505,45 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "light.is_on", + "target": {"floor_id": "floor_repeat_while_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "light.is_on", + "target": {"floor_id": "floor_repeat_until_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4499,6 +4582,11 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_in_data_template", "floor_in_target", "floor_parallel", + "floor_repeat_count_seq", + "floor_repeat_until_cond", + "floor_repeat_until_seq", + "floor_repeat_while_cond", + "floor_repeat_while_seq", "floor_sequence", "floor_service_list", "floor_service_not_list", @@ -4614,6 +4702,45 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "light.is_on", + "target": {"area_id": "area_repeat_while_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "light.is_on", + "target": {"area_id": "area_repeat_until_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4652,6 +4779,11 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_in_data_template", "area_in_target", "area_parallel", + "area_repeat_count_seq", + "area_repeat_until_cond", + "area_repeat_until_seq", + "area_repeat_while_cond", + "area_repeat_while_seq", "area_sequence", "area_service_list", "area_service_not_list", @@ -4787,6 +4919,47 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "state", + "entity_id": "sensor.repeat_while_cond", + "state": "100", + }, + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "state", + "entity_id": "sensor.repeat_until_cond", + "state": "100", + }, + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4825,7 +4998,12 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.if_then", "light.if_else", "light.parallel", + "light.repeat_count_seq", + "light.repeat_until_seq", + "light.repeat_while_seq", "light.sequence", + "sensor.repeat_until_cond", + "sensor.repeat_while_cond", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4948,6 +5126,47 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "repeat-count-seq-device"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "device", + "device_id": "repeat-while-cond-dev-id", + "domain": "switch", + }, + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "repeat-while-seq-device"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "device", + "device_id": "repeat-until-cond-dev-id", + "domain": "switch", + }, + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "repeat-until-seq-device"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "device", @@ -5004,6 +5223,11 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "repeat-count-seq-device", + "repeat-until-cond-dev-id", + "repeat-until-seq-device", + "repeat-while-cond-dev-id", + "repeat-while-seq-device", "sequence-device", "wait-trigger-device", "wait-trigger-target", From 985ef60d8a6d1b68015d311eb8283474773013f8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 09:28:09 +0200 Subject: [PATCH 227/707] Fast-path all template state wrappers in sandbox attribute check (#175397) --- homeassistant/helpers/template/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index fcdba8878ddd..aeda4f51f0f2 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -74,6 +74,7 @@ from .states import ( StateAttrTranslated, StateTranslated, TemplateState as TemplateState, + TemplateStateBase, TemplateStateFromEntityId as TemplateStateFromEntityId, ) @@ -817,7 +818,14 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): def is_safe_attribute(self, obj, attr, value): """Test if attribute is safe.""" if isinstance( - obj, (AllStates, DomainStates, TemplateState, LoopContext, AsyncLoopContext) + obj, + ( + AllStates, + DomainStates, + TemplateStateBase, + LoopContext, + AsyncLoopContext, + ), ): return attr[0] != "_" From 715c355fda347e0cb2797b0897cc2fda12920010 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 09:30:10 +0200 Subject: [PATCH 228/707] Add numeric fast path for template result parsing (#175402) --- homeassistant/helpers/template/__init__.py | 25 +++++++++++++++++++++- tests/helpers/template/test_init.py | 7 ++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index aeda4f51f0f2..23ff8af3f366 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -239,8 +239,31 @@ RESULT_WRAPPERS: dict[type, type] = {kls: gen_result_wrapper(kls) for kls in _ty RESULT_WRAPPERS[tuple] = TupleWrapper -@lru_cache(maxsize=EVAL_CACHE_SIZE) def _parse_result(render_result: str) -> Any: + """Parse a rendered result. + + Continuously changing numeric results, like sensor values, produce + a new string on every render and would always miss the eval cache, + paying for a full literal_eval. Convert them directly instead. + Anything the fast path cannot convert falls through to the cached + path, which handles the edge cases ("", ".", "+") identically. + """ + if _IS_NUMERIC.match(render_result): + if "." in render_result: + try: + return float(render_result) + except ValueError: + pass + else: + try: + return int(render_result) + except ValueError: + pass + return _cached_parse_result(render_result) + + +@lru_cache(maxsize=EVAL_CACHE_SIZE) +def _cached_parse_result(render_result: str) -> Any: """Parse a result and cache the result.""" # lru_cache does not memoize raised exceptions. The most common template # results, plain string states such as "on", "off" or "unavailable", are diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 896c1ea0c1f5..6d1c1a31363f 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -906,6 +906,13 @@ async def test_parse_result(hass: HomeAssistant) -> None: ("-1.0", -1.0), ("+1", 1), ("5.", 5.0), + ("-0", 0), + ("-0.0", -0.0), + ("+", "+"), + ("-", "-"), + (".", "."), + # Exceeds the int digit limit for both int() and literal_eval + ("9" * 5000, "9" * 5000), ("123_123_123", "123_123_123"), # ("+48100200300", "+48100200300"), # phone number ("010", "010"), From a71ec5a80e904b7ec64c5e16eb7c4a29d4600e23 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jul 2026 09:31:29 +0200 Subject: [PATCH 229/707] Downgrade per-step script execution log messages to debug level (#175410) Co-authored-by: David Bonnes --- homeassistant/helpers/script.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 7c4cad41cbe5..0b98324bf2b3 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -451,7 +451,12 @@ class _ScriptRun: _timeout = ( "" if timeout is None else f" (timeout: {timedelta(seconds=timeout)})" ) - self._log("Executing step %s%s", self._script.last_action, _timeout) + self._log( + "Executing step %s%s", + self._script.last_action, + _timeout, + level=logging.DEBUG, + ) async def async_run(self) -> ScriptRunResult | None: """Run script.""" @@ -464,7 +469,11 @@ class _ScriptRun: response = None try: - self._log("Running %s", self._script.running_description) + self._log( + "Running %s", + self._script.running_description, + level=logging.INFO if self._script.top_level else logging.DEBUG, + ) for self._step, self._action in enumerate(self._script.sequence): # noqa: B020 if self._stop.done(): script_execution_set("cancelled") @@ -532,6 +541,7 @@ class _ScriptRun: self._log( "Skipped disabled step %s", self._action.get(CONF_ALIAS, action), + level=logging.DEBUG, ) trace_set_result(enabled=False) return @@ -778,7 +788,12 @@ class _ScriptRun: self._log("Error in 'condition' evaluation:\n%s", ex, level=logging.WARNING) check = False - self._log("Test condition %s: %s", self._script.last_action, check) + self._log( + "Test condition %s: %s", + self._script.last_action, + check, + level=logging.DEBUG, + ) trace_update_result(result=check) if not check: raise _ConditionFail @@ -823,7 +838,13 @@ class _ScriptRun: warned_too_many_loops = False async def async_run_sequence(iteration: int, extra_msg: str = "") -> None: - self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg) + self._log( + "Repeating %s: Iteration %i%s", + description, + iteration, + extra_msg, + level=logging.DEBUG, + ) with trace_path("sequence"): await self._async_run_script(script) From bc1567ebd2f75ff87a9f174d1401d6bc8f7ea8b0 Mon Sep 17 00:00:00 2001 From: Tony Rielly <29752086+ms264556@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:35:55 +0100 Subject: [PATCH 230/707] Bump aioruckus to 0.46.3 (#175824) Co-authored-by: Mick Vleeshouwer --- homeassistant/components/ruckus_unleashed/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 8d56f3a55633..ccb3844b7d90 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioruckus"], - "requirements": ["aioruckus==0.42"] + "requirements": ["aioruckus==0.46.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0bf56d423bdb..025a527d8102 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -401,7 +401,7 @@ aiorecollect==2023.09.0 aioridwell==2025.09.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.42 +aioruckus==0.46.3 # homeassistant.components.russound_rio # homeassistant.components.russound_rnet From 94e6f77273bcd585a6063d593dd1ba759394b56b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:11:32 +0200 Subject: [PATCH 231/707] Use EntityStateAttribute enum in homeassistant (#175932) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/homeassistant/llm.py | 9 +++++++-- homeassistant/components/homeassistant/triggers/time.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index a5608ae274e7..f56df7e31616 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -8,7 +8,8 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.llm import LLMTools -from homeassistant.components.sensor import async_rounded_state +from homeassistant.components.sensor import SensorDeviceClass, async_rounded_state +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( area_registry as ar, @@ -113,7 +114,11 @@ def async_get_exposed_entities( info["state"] = async_rounded_state(hass, state.entity_id, state) # Convert timestamp device_class states from UTC to local time - if state.attributes.get("device_class") == "timestamp" and state.state: + if ( + state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + == SensorDeviceClass.TIMESTAMP + and state.state + ): if (parsed_utc := dt_util.parse_datetime(state.state)) is not None: info["state"] = dt_util.as_local(parsed_utc).isoformat() diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index ded640b55a70..b8d1b91b7097 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.const import ( - ATTR_DEVICE_CLASS, CONF_AT, CONF_ENTITY_ID, CONF_OFFSET, @@ -18,6 +17,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, WEEKDAYS, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -224,7 +224,7 @@ async def async_attach_trigger( # noqa: C901 ) elif ( new_state.domain == "sensor" - and new_state.attributes.get(ATTR_DEVICE_CLASS) + and new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS) in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME) and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): From 2b5e33678c2330bf414ad64bf30536efb099b1b0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:16:42 +0200 Subject: [PATCH 232/707] Use UpdateEntityStateAttribute enum in IronOS (#175936) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/iron_os/update.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index e87711263460..2fd0c0dc7ad7 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -3,11 +3,11 @@ from typing import override from homeassistant.components.update import ( - ATTR_INSTALLED_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -97,7 +97,9 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity, RestoreEntity): Register extra update listener for the firmware update coordinator. """ if state := await self.async_get_last_state(): - self._attr_installed_version = state.attributes.get(ATTR_INSTALLED_VERSION) + self._attr_installed_version = state.attributes.get( + UpdateEntityStateAttribute.INSTALLED_VERSION + ) await super().async_added_to_hass() self.async_on_remove( From e1ee7725618fbf0ee38632891f5af6a4977a3fd0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:18:00 +0200 Subject: [PATCH 233/707] Use LightEntityStateAttribute enum in LimitlessLED (#175938) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/limitlessled/light.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 14b7df583341..bd44820fde92 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + LightEntityStateAttribute, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON from homeassistant.core import HomeAssistant @@ -259,11 +260,15 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): await super().async_added_to_hass() if last_state := await self.async_get_last_state(): self._attr_is_on = last_state.state == STATE_ON - self._attr_brightness = last_state.attributes.get("brightness") + self._attr_brightness = last_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ) self._attr_color_temp_kelvin = last_state.attributes.get( - "color_temp_kelvin" + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ) + self._attr_hs_color = last_state.attributes.get( + LightEntityStateAttribute.HS_COLOR ) - self._attr_hs_color = last_state.attributes.get("hs_color") @property @override From 9be1217c380f2e3a6aabb4bc11449e9601c5eb69 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:19:07 +0200 Subject: [PATCH 234/707] Use EntityStateAttribute enum in min_max (#175941) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/min_max/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index cc1f4b437012..972237a6b6ea 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -16,12 +16,12 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -325,11 +325,11 @@ class MinMaxSensor(SensorEntity): if self._unit_of_measurement is None: self._unit_of_measurement = new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) if self._unit_of_measurement != new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ): _LOGGER.warning( "Units of measurement do not match for entity %s", self.entity_id From ab63b180e421bfd86bac07d6de64c18fd2cc7245 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:19:53 +0200 Subject: [PATCH 235/707] Use EntityStateAttribute enum in mold_indicator (#175942) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/mold_indicator/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 47e0ceb56bed..5dae594c371e 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -16,12 +16,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIQUE_ID, PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfTemperature, ) from homeassistant.core import ( @@ -298,7 +298,9 @@ class MoldIndicator(SensorEntity): ) return None - return validator(value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + return validator( + value, state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ) def _get_temperature_from_state(self, state: State | None) -> float | None: """Get temperature value in Celsius from state.""" From 65ba8eb0067d256edc7a1782abdef1a748a3b4db Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:22:26 +0200 Subject: [PATCH 236/707] Use EntityStateAttribute enum in mobile_app (#175944) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/mobile_app/entity.py | 8 +++++--- homeassistant/components/mobile_app/logbook.py | 6 +++--- homeassistant/components/mobile_app/webhook.py | 7 +++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 4e89b7202fc9..b5798b0db03c 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -5,11 +5,11 @@ from typing import override from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import State, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -93,8 +93,10 @@ class MobileAppEntity(RestoreEntity): **last_state.attributes, **self._config[ATTR_SENSOR_ATTRIBUTES], } - if ATTR_ICON in last_state.attributes: - config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] + if EntityStateAttribute.ICON in last_state.attributes: + config[ATTR_SENSOR_ICON] = last_state.attributes[ + EntityStateAttribute.ICON + ] @property @override diff --git a/homeassistant/components/mobile_app/logbook.py b/homeassistant/components/mobile_app/logbook.py index 8a36eaabd530..1a3e4827eb7a 100644 --- a/homeassistant/components/mobile_app/logbook.py +++ b/homeassistant/components/mobile_app/logbook.py @@ -9,7 +9,7 @@ from homeassistant.components.logbook import ( LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.const import EntityStateAttribute from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util.event_type import EventType @@ -46,8 +46,8 @@ def async_describe_events( zone_name = None zone_icon = None if zone_entity_id and (zone_state := hass.states.get(zone_entity_id)): - zone_name = zone_state.attributes.get(ATTR_FRIENDLY_NAME) - zone_icon = zone_state.attributes.get(ATTR_ICON) + zone_name = zone_state.attributes.get(EntityStateAttribute.FRIENDLY_NAME) + zone_icon = zone_state.attributes.get(EntityStateAttribute.ICON) description = { LOGBOOK_ENTRY_NAME: source_device_name, LOGBOOK_ENTRY_MESSAGE: f"{event_description} {zone_name or zone_entity_id}", diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 0121f5558ecc..c2186f772db6 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -35,11 +35,11 @@ from homeassistant.const import ( ATTR_MODEL, ATTR_SERVICE, ATTR_SERVICE_DATA, - ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID, EntityCategory, + EntityStateAttribute, ) from homeassistant.core import EventOrigin, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError @@ -352,7 +352,10 @@ async def webhook_stream_camera( "mjpeg_path": f"/api/camera_proxy_stream/{camera_state.entity_id}" } - if camera_state.attributes[ATTR_SUPPORTED_FEATURES] & CameraEntityFeature.STREAM: + if ( + camera_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + & CameraEntityFeature.STREAM + ): try: resp["hls_path"] = await camera.async_request_stream( hass, camera_state.entity_id, "hls" From 64e0d2eae0ea3f9eec4e15be46fbefa71cf68537 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:21:29 +0200 Subject: [PATCH 237/707] Use LightEntityStateAttribute enum in RFXtrx (#175955) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/rfxtrx/light.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 77316c8d1323..99af025f1c0a 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -5,7 +5,12 @@ from typing import Any, override import RFXtrx as rfxtrxmod -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback @@ -70,7 +75,9 @@ class RfxtrxLight(RfxtrxCommandEntity, LightEntity): old_state = await self.async_get_last_state() if old_state is not None: self._attr_is_on = old_state.state == STATE_ON - if brightness := old_state.attributes.get(ATTR_BRIGHTNESS): + if brightness := old_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ): self._attr_brightness = int(brightness) @override From c68046271005bc267aaf84358965485b5e6f8e48 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:41:49 +0200 Subject: [PATCH 238/707] Use LightEntityStateAttribute enum in Pilight (#175953) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/pilight/entity.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index 5d4196537880..6b49adbfcf6b 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -4,6 +4,7 @@ from typing import Any, override import voluptuous as vol +from homeassistant.components.light import LightEntityStateAttribute from homeassistant.const import ( CONF_ID, CONF_NAME, @@ -97,7 +98,9 @@ class PilightBaseDevice(RestoreEntity): await super().async_added_to_hass() if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - self._brightness = state.attributes.get("brightness") + self._brightness = state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ) def _handle_code(self, call): """Check if received code by the pilight-daemon. From 691038841b19d18ed3cf2a58d07f04bc883c06e5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:42:12 +0200 Subject: [PATCH 239/707] Use FanEntityStateAttribute enum in Snooz (#175960) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/snooz/fan.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index 7ec6eb89b6f5..19db35212796 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -14,7 +14,11 @@ from pysnooz.commands import ( ) import voluptuous as vol -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + FanEntity, + FanEntityFeature, + FanEntityStateAttribute, +) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -109,7 +113,9 @@ class SnoozFan(FanEntity, RestoreEntity): self._is_on = last_state.state == STATE_ON else: self._is_on = None - self._percentage = last_state.attributes.get(ATTR_PERCENTAGE) + self._percentage = last_state.attributes.get( + FanEntityStateAttribute.PERCENTAGE + ) self.async_on_remove(self._async_subscribe_to_device_change()) From 89b7a9174209224aa05e70699c13099053cfb621 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:42:35 +0200 Subject: [PATCH 240/707] Use EntityStateAttribute enum in InfluxDB (#175935) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/influxdb/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 09f3b795cbd9..fb11066fbb09 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -39,6 +39,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.data_entry_flow import FlowResultType @@ -252,7 +253,9 @@ def _generate_event_to_json(conf: dict) -> Callable[[Event], dict[str, Any] | No if measurement_attr == "entity_id": measurement = state.entity_id elif measurement_attr == "domain__device_class": - device_class = state.attributes.get("device_class") + device_class = state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) if device_class is None: # This entity doesn't have a device_class set, use only domain measurement = state.domain From 2582f6f5a9ee2a3e146bae07eae9d0208cbed10a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:42:59 +0200 Subject: [PATCH 241/707] Use EntityStateAttribute enum in integration (#175933) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/integration/config_flow.py | 14 ++++++-------- homeassistant/components/integration/sensor.py | 10 ++++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 7d00d2161cc0..718fee7893c5 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -8,12 +8,7 @@ import voluptuous as vol from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_METHOD, - CONF_NAME, - UnitOfTime, -) +from homeassistant.const import CONF_METHOD, CONF_NAME, EntityStateAttribute, UnitOfTime from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -62,13 +57,16 @@ def entity_selector_compatible( """Return an entity selector which compatible entities.""" current = handler.hass.states.get(handler.options[CONF_SOURCE_SENSOR]) unit_of_measurement = ( - current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None + current.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + if current + else None ) entities = [ ent.entity_id for ent in handler.hass.states.async_all(ALLOWED_DOMAINS) - if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement + if ent.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + == unit_of_measurement and ent.domain in ALLOWED_DOMAINS ] diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 83b93c4191d0..edd7c5d0c1d0 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -20,12 +20,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_METHOD, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, + EntityStateAttribute, UnitOfTime, ) from homeassistant.core import ( @@ -389,7 +388,9 @@ class IntegrationSensor(RestoreSensor): return device_class def _derive_and_set_attributes_from_state(self, source_state: State) -> None: - source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + source_unit = source_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) if source_unit is not None: self._unit_of_measurement = self._calculate_unit(source_unit) else: @@ -397,7 +398,8 @@ class IntegrationSensor(RestoreSensor): self._unit_of_measurement = None self._attr_device_class = self._calculate_device_class( - source_state.attributes.get(ATTR_DEVICE_CLASS), self.unit_of_measurement + source_state.attributes.get(EntityStateAttribute.DEVICE_CLASS), + self.unit_of_measurement, ) if self._attr_device_class: # Remove this sensors icon default and allow From 7e9c040eb0bf5bebb3ab564925aafa3ad9b68b4e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:49:03 +0200 Subject: [PATCH 242/707] Use CoverEntityStateAttribute enum in SwitchBot (#175961) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/switchbot/cover.py | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 683f98ec4b27..7487e64d312f 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -6,13 +6,12 @@ from typing import Any, override import switchbot from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, + CoverEntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -77,11 +76,14 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes + ): return self._attr_current_cover_position = last_state.attributes.get( - ATTR_CURRENT_POSITION + CoverEntityStateAttribute.CURRENT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if self._attr_current_cover_position is not None: @@ -172,11 +174,15 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_TILT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_TILT_POSITION + not in last_state.attributes + ): return self._attr_current_cover_tilt_position = last_state.attributes.get( - ATTR_CURRENT_TILT_POSITION + CoverEntityStateAttribute.CURRENT_TILT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if (_tilt := self._attr_current_cover_tilt_position) is not None: @@ -260,11 +266,14 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes + ): return self._attr_current_cover_position = last_state.attributes.get( - ATTR_CURRENT_POSITION + CoverEntityStateAttribute.CURRENT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if self._attr_current_cover_position is not None: From 61e6562ca812c8f81b83138cd3680983120919cf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:57:39 +0200 Subject: [PATCH 243/707] Use FanEntityStateAttribute enum in Novy Cooker Hood (#175948) --- homeassistant/components/novy_cooker_hood/fan.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index f59281e6c7e3..f2a8b0e6f694 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -6,7 +6,11 @@ from typing import Any, override from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from rf_protocols.commands.novy import NovyCookerHoodCommand -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + FanEntity, + FanEntityFeature, + FanEntityStateAttribute, +) from homeassistant.components.radio_frequency import async_send_command from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE @@ -74,7 +78,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): last = await self.async_get_last_state() if last is None: return - last_pct = last.attributes.get(ATTR_PERCENTAGE) + last_pct = last.attributes.get(FanEntityStateAttribute.PERCENTAGE) if isinstance(last_pct, (int, float)) and last_pct > 0: self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct)) From 3c9ce2f1b46821126c0e5f451db22b702e5469c1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:06:54 +0200 Subject: [PATCH 244/707] Expose Attribute enum in device_tracker and text (#175502) --- homeassistant/components/device_tracker/__init__.py | 4 ++++ homeassistant/components/text/__init__.py | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 8f1489256a6a..6e50faa06eb2 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -36,7 +36,11 @@ from .const import ( # noqa: F401 LOGGER, PLATFORM_TYPE_LEGACY, SCAN_INTERVAL, + DeviceTrackerEntityCapabilityAttribute, + DeviceTrackerEntityStateAttribute, + ScannerEntityStateAttribute, SourceType, + TrackerEntityStateAttribute, TrackingType, ) from .entity import ( # noqa: F401 diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index eebfd29452b8..911f3c6aa391 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -41,7 +41,13 @@ SCAN_INTERVAL = timedelta(seconds=30) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -__all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"] +__all__ = [ + "DOMAIN", + "TextEntity", + "TextEntityCapabilityAttribute", + "TextEntityDescription", + "TextMode", +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: From 8aed1ba12cf5fa45b504ea98646a678c680ee732 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:16:41 +0200 Subject: [PATCH 245/707] Update mypy to 2.2.0 (#175924) --- homeassistant/components/apple_tv/media_player.py | 2 +- homeassistant/components/conversation/trigger.py | 3 +-- homeassistant/components/home_connect/const.py | 2 +- homeassistant/components/http/__init__.py | 2 +- homeassistant/components/lcn/device_trigger.py | 4 +++- homeassistant/components/mcp_server/http.py | 5 +++++ homeassistant/components/unifiprotect/utils.py | 2 +- homeassistant/components/zwave_js/climate.py | 2 +- homeassistant/runner.py | 7 ++++--- requirements_test.txt | 6 +++--- 10 files changed, 21 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 0a39f0ab6f47..d905b3214bde 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -204,7 +204,7 @@ class AppleTvMediaPlayer( return MediaPlayerState.PLAYING if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped): return MediaPlayerState.PAUSED - return MediaPlayerState.IDLE # Bad or unknown state? + return MediaPlayerState.IDLE # type: ignore[unreachable] # Bad or unknown state? return None @callback diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 2f73f7c33d8f..31ac35cb756c 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -151,8 +151,7 @@ async def async_attach_trigger( if isinstance( automation_result, ScriptRunResult ) and automation_result.conversation_response not in (None, UNDEFINED): - # mypy does not understand the type narrowing, unclear why - return automation_result.conversation_response # type: ignore[return-value] + return automation_result.conversation_response # It's important to return None here instead of a string. # diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 1141b3a29737..94c8c7216af6 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -104,7 +104,7 @@ TRANSLATION_KEYS_PROGRAMS_MAP = { if program not in (ProgramKey.UNKNOWN, *FAVORITE_PROGRAMS) } -PROGRAMS_TRANSLATION_KEYS_MAP = { +PROGRAMS_TRANSLATION_KEYS_MAP: dict[ProgramKey, str] = { value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 85a4c3ead687..9051f423fddd 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -471,7 +471,7 @@ class HomeAssistantHTTP: async def redirect(request: web.Request) -> web.StreamResponse: """Redirect to location.""" # Should be instance of aiohttp.web_exceptions._HTTPMove. - raise redirect_exc(redirect_to) # type: ignore[arg-type,misc] + raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg] self.app[KEY_ALLOW_CONFIGURED_CORS]( self.app.router.add_route("GET", url, redirect) diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py index 2421fa0b9f5e..799103e71c06 100644 --- a/homeassistant/components/lcn/device_trigger.py +++ b/homeassistant/components/lcn/device_trigger.py @@ -1,5 +1,7 @@ """Provides device triggers for LCN.""" +from typing import Any + import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -75,7 +77,7 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - event_data = { + event_data: dict[str, Any] = { CONF_DEVICE_ID: config[CONF_DEVICE_ID], **{ key: config[key] diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 4317bb3d5494..6f2857f0d9ca 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -118,6 +118,11 @@ class Streams: @asynccontextmanager async def create_streams() -> AsyncGenerator[Streams]: """Create a new pair of streams for MCP server communication.""" + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] + read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) streams = Streams( diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 9e5f7af14c0b..7a6deddcc4b5 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -56,7 +56,7 @@ def _async_short_mac(mac: str) -> str: return _async_unifi_mac_from_hass(mac)[-6:] -async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: +async def _async_resolve(hass: HomeAssistant, host: str) -> str | int | None: """Resolve a hostname to an ip.""" with contextlib.suppress(OSError): return next( diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index b9e533742690..131d8e1fcb84 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -557,7 +557,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # and cool to mirror previous behavior. If none of those are available, set it # to the first available mode that is not off. try: - hvac_mode = next( + hvac_mode: HVACMode = next( mode for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) if mode in self._hvac_modes diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 61936a49c0d3..0150f93fe15b 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -18,7 +18,7 @@ import threading import time from time import monotonic import traceback -from typing import Any +from typing import Any, override import packaging.tags @@ -173,7 +173,7 @@ class RuntimeConfig: safe_mode: bool = False -class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[name-defined,misc] +class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): """Event loop policy for Home Assistant.""" def __init__(self, debug: bool) -> None: @@ -184,8 +184,9 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[name- @property def loop_name(self) -> str: """Return name of the loop.""" - return self._loop_factory.__name__ # type: ignore[no-any-return] + return self._loop_factory.__name__ # type: ignore[attr-defined,no-any-return] + @override def new_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop.""" loop: asyncio.AbstractEventLoop = super().new_event_loop() diff --git a/requirements_test.txt b/requirements_test.txt index 7378f18e9778..c5156f0088bd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,15 +8,15 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt # ast-serialize is an internal mypy dependency -ast-serialize==0.3.0 +ast-serialize==0.6.0 astroid==4.0.4 coverage==7.14.3 freezegun==1.5.5 # librt is an internal mypy dependency -librt==0.11.0 +librt==0.12.0 license-expression==30.4.3 mock-open==1.4.0 -mypy==2.1.0 +mypy==2.2.0 prek==0.2.28 pydantic==2.13.4 PyGithub==2.9.1 From e563c567a27ddb0fec5ac730348310830f481b41 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:25:14 +0200 Subject: [PATCH 246/707] Migrate zone entity attributes to StrEnum (#175755) --- homeassistant/components/zone/__init__.py | 56 +++++++++++-------- homeassistant/components/zone/const.py | 15 +++++ .../snapshots/test_device_tracker.ambr | 38 ++++++------- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index b0a14960fca7..07e6ff6d0c46 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -9,7 +9,7 @@ from typing import Any, Self, cast, override import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -45,7 +45,14 @@ from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util.hass_dict import HassKey from homeassistant.util.location import distance -from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE +from .const import ( # noqa: F401 + ATTR_PASSIVE, + ATTR_RADIUS, + CONF_PASSIVE, + DOMAIN, + HOME_ZONE, + ZoneEntityStateAttribute, +) _LOGGER = logging.getLogger(__name__) @@ -142,21 +149,24 @@ def async_in_zones( zone_dist := distance( latitude, longitude, - zone_attrs[ATTR_LATITUDE], - zone_attrs[ATTR_LONGITUDE], + zone_attrs[ZoneEntityStateAttribute.LATITUDE], + zone_attrs[ZoneEntityStateAttribute.LONGITUDE], ) ) is None # Skip zone that are outside the radius aka the # lat/long is outside the zone - or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius) + or not ( + zone_dist - (zone_radius := zone_attrs[ZoneEntityStateAttribute.RADIUS]) + < radius + ) ): continue zones.append((zone.entity_id, zone_dist, zone_radius)) # Skip passive zones - if zone_attrs.get(ATTR_PASSIVE): + if zone_attrs.get(ZoneEntityStateAttribute.PASSIVE): continue # Prefer the smallest zone, using distance to its center as a tie @@ -198,9 +208,9 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ ): return [] input_attrs = input_zone.attributes - input_latitude: float = input_attrs[ATTR_LATITUDE] - input_longitude: float = input_attrs[ATTR_LONGITUDE] - input_radius: float = input_attrs[ATTR_RADIUS] + input_latitude: float = input_attrs[ZoneEntityStateAttribute.LATITUDE] + input_longitude: float = input_attrs[ZoneEntityStateAttribute.LONGITUDE] + input_radius: float = input_attrs[ZoneEntityStateAttribute.RADIUS] zones: list[tuple[str, float, float]] = [] @@ -221,12 +231,12 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ zone_dist := distance( input_latitude, input_longitude, - zone_attrs[ATTR_LATITUDE], - zone_attrs[ATTR_LONGITUDE], + zone_attrs[ZoneEntityStateAttribute.LATITUDE], + zone_attrs[ZoneEntityStateAttribute.LONGITUDE], ) ) is None: continue - zone_radius = zone_attrs[ATTR_RADIUS] + zone_radius = zone_attrs[ZoneEntityStateAttribute.RADIUS] if not zone_dist + input_radius <= zone_radius: continue zones.append((zone.entity_id, zone_dist, zone_radius)) @@ -281,13 +291,15 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - zone_dist = distance( latitude, longitude, - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[ZoneEntityStateAttribute.LATITUDE], + zone.attributes[ZoneEntityStateAttribute.LONGITUDE], ) - if zone_dist is None or zone.attributes[ATTR_RADIUS] is None: + if zone_dist is None or zone.attributes[ZoneEntityStateAttribute.RADIUS] is None: return False - return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS]) + return zone_dist - radius < cast( + float, zone.attributes[ZoneEntityStateAttribute.RADIUS] + ) class ZoneStorageCollection(collection.DictStorageCollection): @@ -508,12 +520,12 @@ class Zone(collection.CollectionEntity): def _generate_attrs(self) -> None: """Generate new attrs based on config.""" self._attr_extra_state_attributes = { - ATTR_LATITUDE: self._config[CONF_LATITUDE], - ATTR_LONGITUDE: self._config[CONF_LONGITUDE], - ATTR_RADIUS: self._config[CONF_RADIUS], - ATTR_PASSIVE: self._config[CONF_PASSIVE], - ATTR_PERSONS: sorted(self._persons_in_zone), - ATTR_EDITABLE: self.editable, + ZoneEntityStateAttribute.LATITUDE: self._config[CONF_LATITUDE], + ZoneEntityStateAttribute.LONGITUDE: self._config[CONF_LONGITUDE], + ZoneEntityStateAttribute.RADIUS: self._config[CONF_RADIUS], + ZoneEntityStateAttribute.PASSIVE: self._config[CONF_PASSIVE], + ZoneEntityStateAttribute.PERSONS: sorted(self._persons_in_zone), + ZoneEntityStateAttribute.EDITABLE: self.editable, } @callback diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index af6ac00cbbb5..9e9215d6f23a 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -1,7 +1,22 @@ """Constants for the zone component.""" +from enum import StrEnum + CONF_PASSIVE = "passive" DOMAIN = "zone" HOME_ZONE = "home" + + +class ZoneEntityStateAttribute(StrEnum): + """State attributes for zone entities.""" + + LATITUDE = "latitude" + LONGITUDE = "longitude" + RADIUS = "radius" + PASSIVE = "passive" + PERSONS = "persons" + EDITABLE = "editable" + + ATTR_PASSIVE = "passive" ATTR_RADIUS = "radius" diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index d4e2620bbe64..6f11fea1a50b 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -1,25 +1,6 @@ # serializer version: 1 # name: test_states set({ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'editable': True, - : 'test home', - : 'mdi:home', - 'latitude': 32.87336, - 'longitude': -117.22743, - 'passive': False, - 'persons': list([ - ]), - 'radius': 100, - }), - 'context': , - 'entity_id': 'zone.home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }), StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Demo scanner', @@ -55,5 +36,24 @@ 'last_updated': , 'state': 'home', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'test home', + : 'mdi:home', + : 32.87336, + : -117.22743, + : False, + : list([ + ]), + : 100, + }), + 'context': , + 'entity_id': 'zone.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), }) # --- From 9c2061159460b0558e2457f948a85f72667c3e59 Mon Sep 17 00:00:00 2001 From: hplato Date: Wed, 8 Jul 2026 06:55:25 -0600 Subject: [PATCH 247/707] Bump venstarcolortouch to 0.22 (#175867) --- homeassistant/components/venstar/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index eba5c8a6cd48..ddba18fd9daa 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["venstarcolortouch"], - "requirements": ["venstarcolortouch==0.21"] + "requirements": ["venstarcolortouch==0.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 025a527d8102..7ec3a587c907 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3290,7 +3290,7 @@ vehicle==3.0.0 velbus-aio==2026.4.1 # homeassistant.components.venstar -venstarcolortouch==0.21 +venstarcolortouch==0.22 # homeassistant.components.viaggiatreno viaggiatreno_ha==0.2.4 From 050ab7a5547fda28067ecfaba623da39deee0440 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:08:33 +0200 Subject: [PATCH 248/707] Use UpdateEntityStateAttribute enum in Matter (#175945) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/matter/update.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 776d895186a8..26c12da16775 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -9,11 +9,11 @@ from matter_server.common.errors import UpdateCheckError, UpdateError from matter_server.common.models import MatterSoftwareVersion, UpdateSource from homeassistant.components.update import ( - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -204,7 +204,9 @@ class MatterUpdate(MatterEntity, UpdateEntity): await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._attr_latest_version = state.attributes.get(ATTR_LATEST_VERSION) + self._attr_latest_version = state.attributes.get( + UpdateEntityStateAttribute.LATEST_VERSION + ) if (extra_data := await self.async_get_last_extra_data()) and ( matter_extra_data := MatterUpdateExtraStoredData.from_dict( From 28003fe08fb566fb2639c6f935914f5971584b4c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:19:33 +0200 Subject: [PATCH 249/707] Use state attribute enums in Modbus (#175946) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/modbus/climate.py | 7 +++++-- homeassistant/components/modbus/light.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 402bf30d7a54..8d2590d442fa 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -314,8 +315,10 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() - if state and state.attributes.get(ATTR_TEMPERATURE): - self._attr_target_temperature = float(state.attributes[ATTR_TEMPERATURE]) + if state and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE): + self._attr_target_temperature = float( + state.attributes[ClimateEntityStateAttribute.TEMPERATURE] + ) @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index a829e6432e2f..39c1c60754af 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -7,6 +7,7 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP_KELVIN, ColorMode, LightEntity, + LightEntityStateAttribute, ) from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant @@ -79,10 +80,16 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if (state := await self.async_get_last_state()) is None: return - if (brightness := state.attributes.get(ATTR_BRIGHTNESS)) is not None: + if ( + brightness := state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) + ) is not None: self._attr_brightness = brightness - if (color_temp := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + if ( + color_temp := state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ) + ) is not None: self._attr_color_temp_kelvin = color_temp @staticmethod From 0d538df9215833207dbb0fe194469a5379d394b4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:30:17 +0200 Subject: [PATCH 250/707] Use ZoneEntityStateAttribute enum in Open-Meteo (#175978) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/open_meteo/coordinator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py index e4834994a12b..7e8066c1d748 100644 --- a/homeassistant/components/open_meteo/coordinator.py +++ b/homeassistant/components/open_meteo/coordinator.py @@ -13,8 +13,9 @@ from open_meteo import ( WindSpeedUnit, ) +from homeassistant.components.zone import ZoneEntityStateAttribute from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE +from homeassistant.const import CONF_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -49,8 +50,8 @@ class OpenMeteoDataUpdateCoordinator(DataUpdateCoordinator[Forecast]): try: return await self.open_meteo.forecast( - latitude=zone.attributes[ATTR_LATITUDE], - longitude=zone.attributes[ATTR_LONGITUDE], + latitude=zone.attributes[ZoneEntityStateAttribute.LATITUDE], + longitude=zone.attributes[ZoneEntityStateAttribute.LONGITUDE], current_weather=True, daily=[ DailyParameters.PRECIPITATION_SUM, From e05ae4ee5f96505c3c32e5bd8de8fa6fcad38fa9 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 8 Jul 2026 15:31:03 +0200 Subject: [PATCH 251/707] Rename component to integration in Etherscan (#175535) --- homeassistant/components/etherscan/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/etherscan/__init__.py b/homeassistant/components/etherscan/__init__.py index 0e983bd6bead..b7665204e229 100644 --- a/homeassistant/components/etherscan/__init__.py +++ b/homeassistant/components/etherscan/__init__.py @@ -1 +1 @@ -"""The etherscan component.""" +"""The Etherscan integration.""" From 1020c06e5219f739868f845b967a4277c52d52eb Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 8 Jul 2026 15:43:42 +0200 Subject: [PATCH 252/707] Remove previously deprecated battery props from SwitchBot Cloud (#175688) --- homeassistant/components/switchbot_cloud/vacuum.py | 4 +--- tests/components/switchbot_cloud/test_init.py | 2 -- tests/components/switchbot_cloud/test_vacuum.py | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 892b1f1bd021..7c3ff8f5584b 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -75,8 +75,7 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): # "Robot Vacuum Cleaner S1 Plus" _attr_supported_features: VacuumEntityFeature = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -123,7 +122,6 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): if self.coordinator.data is None: return - self._attr_battery_level = self.coordinator.data.get("battery") self._attr_available = self.coordinator.data.get("onlineStatus") == "online" switchbot_state = str(self.coordinator.data.get("workingStatus")) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 10c6c2f06d1b..6877a9ebc14f 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -300,7 +300,6 @@ async def test_polling_is_only_disabled_after_webhook_delivery( entity_id = "vacuum.vacuum_name_1" state = hass.states.get(entity_id) assert state is not None - assert state.attributes["battery_level"] == 71 # Change API return values and wait for update mock_get_status.return_value = { @@ -317,7 +316,6 @@ async def test_polling_is_only_disabled_after_webhook_delivery( # Validate that the state was updated again via fetch state = hass.states.get(entity_id) assert state is not None - assert state.attributes["battery_level"] == 60 hass.bus.async_fire(EVENT_HOMEASSISTANT_START) webhook_id = entry.data[CONF_WEBHOOK_ID] diff --git a/tests/components/switchbot_cloud/test_vacuum.py b/tests/components/switchbot_cloud/test_vacuum.py index 71b237aafa2a..3410be3c7bb4 100644 --- a/tests/components/switchbot_cloud/test_vacuum.py +++ b/tests/components/switchbot_cloud/test_vacuum.py @@ -293,7 +293,6 @@ async def test_k10_plus_webhook_updates_state_after_reload( state = hass.states.get(entity_id) assert state is not None assert state.state == VacuumActivity.CLEANING.value - assert state.attributes["battery_level"] == 74 async def test_k20_plus_pro_set_fan_speed( From 0232f4042b4ae41735999e5040491d2dc6c2ea80 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 15:47:19 +0200 Subject: [PATCH 253/707] Add assist_satellite LLM tools platform (#175655) Co-authored-by: Claude Opus 4.8 Co-authored-by: Michael Hansen --- .../components/assist_satellite/llm.py | 24 +++++++++++++ tests/components/assist_satellite/test_llm.py | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 homeassistant/components/assist_satellite/llm.py create mode 100644 tests/components/assist_satellite/test_llm.py diff --git a/homeassistant/components/assist_satellite/llm.py b/homeassistant/components/assist_satellite/llm.py new file mode 100644 index 000000000000..6590875fe4bc --- /dev/null +++ b/homeassistant/components/assist_satellite/llm.py @@ -0,0 +1,24 @@ +"""LLM tools for the assist_satellite integration.""" + +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the broadcast LLM tool.""" + if api_id != LLM_API_ASSIST: + return None + + # assist_satellite registers the broadcast intent when it is set up, and + # this platform is only queried once that has happened. + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type == intent.INTENT_BROADCAST + ] + return LLMTools(tools=tools) diff --git a/tests/components/assist_satellite/test_llm.py b/tests/components/assist_satellite/test_llm.py new file mode 100644 index 000000000000..9a65b35b9f8a --- /dev/null +++ b/tests/components/assist_satellite/test_llm.py @@ -0,0 +1,34 @@ +"""Tests for the assist_satellite LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def init_integrations(hass: HomeAssistant) -> None: + """Set up the integrations; assist_satellite registers the broadcast intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "llm", {}) + assert await async_setup_component(hass, "assist_satellite", {}) + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_broadcast_tool_offered(hass: HomeAssistant) -> None: + """Test the broadcast intent is exposed as an LLM tool.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "HassBroadcast" in [tool.name for tool in result.tools] From fbbbc18f3a0421f1438d7acbe0c5acaede7e92fe Mon Sep 17 00:00:00 2001 From: Bradley Florence <11214309+bradleyseanf@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:48:52 -0400 Subject: [PATCH 254/707] Replace MonarchMoney Integration Dependency to MonarchMoneyCommunity (#175721) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 4 ++-- homeassistant/components/monarch_money/config_flow.py | 5 ++--- homeassistant/components/monarch_money/manifest.json | 4 ++-- requirements_all.txt | 6 +++--- tests/components/monarch_money/snapshots/test_sensor.ambr | 8 ++++---- tests/components/monarch_money/test_config_flow.py | 4 +++- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index bc489bbe4f9b..9c620246f027 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1145,8 +1145,8 @@ CLAUDE.md @home-assistant/core /tests/components/moehlenhoff_alpha2/ @j-a-n /homeassistant/components/moisture/ @home-assistant/core /tests/components/moisture/ @home-assistant/core -/homeassistant/components/monarch_money/ @jeeftor -/tests/components/monarch_money/ @jeeftor +/homeassistant/components/monarch_money/ @jeeftor @bradleyseanf +/tests/components/monarch_money/ @jeeftor @bradleyseanf /homeassistant/components/monoprice/ @etsinko @OnFreund /tests/components/monoprice/ @etsinko @OnFreund /homeassistant/components/monzo/ @jakemartin-icl diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index a0d4d714bc8c..df2d01d96b70 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -68,9 +68,8 @@ async def validate_login( LOGGER.debug("Attempting to authenticate with MFA code") try: await monarch_client.multi_factor_authenticate(email, password, mfa_code) - except KeyError as err: - # A bug in the backing lib that I don't control - # throws a KeyError if the MFA code is wrong + except (KeyError, RequireMFAException, LoginFailedException) as err: + # Backing library MFA failures can surface as a KeyError or auth error. LOGGER.debug("Bad MFA Code") raise BadMFA from err else: diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json index 7ada72badb1a..015418de814e 100644 --- a/homeassistant/components/monarch_money/manifest.json +++ b/homeassistant/components/monarch_money/manifest.json @@ -1,10 +1,10 @@ { "domain": "monarch_money", "name": "Monarch Money", - "codeowners": ["@jeeftor"], + "codeowners": ["@jeeftor", "@bradleyseanf"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/monarchmoney", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["typedmonarchmoney==0.7.0"] + "requirements": ["monarchmoneycommunity==1.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7ec3a587c907..9ebe7825b0b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1597,6 +1597,9 @@ moehlenhoff-alpha2==1.4.0 # homeassistant.components.route_b_smart_meter momonga==0.3.0 +# homeassistant.components.monarch_money +monarchmoneycommunity==1.5.1 + # homeassistant.components.monzo monzopy==1.5.1 @@ -3236,9 +3239,6 @@ twilio==6.32.0 # homeassistant.components.twitch twitchAPI==4.2.1 -# homeassistant.components.monarch_money -typedmonarchmoney==0.7.0 - # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr index a73e71086ddf..19322633e2c1 100644 --- a/tests/components/monarch_money/snapshots/test_sensor.ambr +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -1020,7 +1020,7 @@ # name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_data_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via vin_audit', : 'timestamp', : 'VinAudit 2050 Toyota RAV8 Data age', }), @@ -1074,7 +1074,7 @@ # name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_value-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via vin_audit', : 'monetary', : 'https://api.monarchmoney.com/cdn-cgi/image/width=128/images/institution/159427559853802644', : 'VinAudit 2050 Toyota RAV8 Value', @@ -1131,7 +1131,7 @@ # name: test_all_entities[sensor.zillow_house_balance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via zillow', : 'monetary', : 'data:image/png;base64,base64Nonce', : 'Zillow House Balance', @@ -1186,7 +1186,7 @@ # name: test_all_entities[sensor.zillow_house_data_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via zillow', : 'timestamp', : 'Zillow House Data age', }), diff --git a/tests/components/monarch_money/test_config_flow.py b/tests/components/monarch_money/test_config_flow.py index d7a23e2e385c..b33b5f0ff676 100644 --- a/tests/components/monarch_money/test_config_flow.py +++ b/tests/components/monarch_money/test_config_flow.py @@ -134,7 +134,9 @@ async def test_form_mfa( assert result["step_id"] == "user" # Add a bad MFA Code response - mock_config_api.return_value.multi_factor_authenticate.side_effect = KeyError + mock_config_api.return_value.multi_factor_authenticate.side_effect = ( + LoginFailedException("Bad MFA code") + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], { From 3bd3722cadeaa5789ac3cff15ba9416d00f1acaa Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 16:02:27 +0200 Subject: [PATCH 255/707] MELCloud add text selectors (#175131) --- .../components/melcloud/config_flow.py | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index d9e367be06d0..d0b576631bcc 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -12,9 +12,28 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import DOMAIN +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="username") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -69,9 +88,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), + data_schema=USER_SCHEMA, ) return await self._create_client( username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD] @@ -98,9 +115,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), + data_schema=USER_SCHEMA, errors=errors, ) @@ -180,7 +195,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, From f00dfc7e16e286d38c06b4907c9c9a75e3e4b3da Mon Sep 17 00:00:00 2001 From: kristbaum Date: Wed, 8 Jul 2026 16:05:28 +0200 Subject: [PATCH 256/707] Add Luci openwrt scanner config flow (#167308) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 1 + homeassistant/components/luci/__init__.py | 58 +++++ homeassistant/components/luci/config_flow.py | 139 ++++++++++++ homeassistant/components/luci/const.py | 10 + homeassistant/components/luci/coordinator.py | 55 +++++ .../components/luci/device_tracker.py | 190 +++++++++------- homeassistant/components/luci/manifest.json | 2 + homeassistant/components/luci/strings.json | 41 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/luci/__init__.py | 1 + tests/components/luci/conftest.py | 93 ++++++++ .../luci/snapshots/test_device_tracker.ambr | 121 +++++++++++ tests/components/luci/test_config_flow.py | 203 ++++++++++++++++++ tests/components/luci/test_device_tracker.py | 60 ++++++ tests/components/luci/test_init.py | 110 ++++++++++ 16 files changed, 1013 insertions(+), 74 deletions(-) create mode 100644 homeassistant/components/luci/config_flow.py create mode 100644 homeassistant/components/luci/const.py create mode 100644 homeassistant/components/luci/coordinator.py create mode 100644 homeassistant/components/luci/strings.json create mode 100644 tests/components/luci/__init__.py create mode 100644 tests/components/luci/conftest.py create mode 100644 tests/components/luci/snapshots/test_device_tracker.ambr create mode 100644 tests/components/luci/test_config_flow.py create mode 100644 tests/components/luci/test_device_tracker.py create mode 100644 tests/components/luci/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 9c620246f027..bf877ec95f1b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1055,6 +1055,7 @@ CLAUDE.md @home-assistant/core /homeassistant/components/lovelace/ @home-assistant/frontend /tests/components/lovelace/ @home-assistant/frontend /homeassistant/components/luci/ @mzdrale +/tests/components/luci/ @mzdrale /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck /homeassistant/components/lunatone/ @MoonDevLT diff --git a/homeassistant/components/luci/__init__.py b/homeassistant/components/luci/__init__.py index b0efa61ae778..f39ef47578c6 100644 --- a/homeassistant/components/luci/__init__.py +++ b/homeassistant/components/luci/__init__.py @@ -1 +1,59 @@ """The luci component.""" + +from openwrt_luci_rpc import OpenWrtRpc +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, PLATFORMS +from .coordinator import LuciConfigEntry, LuciCoordinator + + +def _connect( + host: str, username: str, password: str, ssl: bool, verify_ssl: bool +) -> OpenWrtRpc: + """Connect to the router and verify login.""" + router = OpenWrtRpc(host, username, password, ssl, verify_ssl) + if not router.is_logged_in(): + raise ConfigEntryAuthFailed("Invalid credentials for router") + return router + + +async def async_setup_entry(hass: HomeAssistant, entry: LuciConfigEntry) -> bool: + """Set up OpenWrt (luci) from a config entry.""" + try: + router = await hass.async_add_executor_job( + _connect, + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data.get(CONF_SSL, DEFAULT_SSL), + entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) + except (ConnectionError, RequestsConnectionError) as err: + raise ConfigEntryNotReady( + f"Cannot connect to router at {entry.data[CONF_HOST]}" + ) from err + + coordinator = LuciCoordinator(hass, entry, router) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LuciConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/luci/config_flow.py b/homeassistant/components/luci/config_flow.py new file mode 100644 index 000000000000..c5c8a191ab1f --- /dev/null +++ b/homeassistant/components/luci/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow for the OpenWrt (luci) integration.""" + +from collections.abc import Mapping +from typing import Any, override + +from openwrt_luci_rpc import OpenWrtRpc +from requests.exceptions import ConnectionError as RequestsConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +class InvalidAuth(Exception): + """Raised when authentication fails.""" + + +class LuciConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenWrt (luci).""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(_try_connect, user_input) + except ConnectionError, RequestsConnectionError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + _try_connect, reauth_entry.data | user_input + ) + except ConnectionError, RequestsConnectionError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Handle import from legacy YAML configuration.""" + self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(_try_connect, import_data) + except ConnectionError, RequestsConnectionError: + return self.async_abort(reason="cannot_connect") + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + + return self.async_create_entry( + title=import_data[CONF_HOST], + data=import_data, + ) + + +def _try_connect(user_input: dict[str, Any]) -> None: + """Try to connect and authenticate with the router.""" + router = OpenWrtRpc( + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input.get(CONF_SSL, DEFAULT_SSL), + user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) + if not router.is_logged_in(): + raise InvalidAuth diff --git a/homeassistant/components/luci/const.py b/homeassistant/components/luci/const.py new file mode 100644 index 000000000000..e8d34b6b3354 --- /dev/null +++ b/homeassistant/components/luci/const.py @@ -0,0 +1,10 @@ +"""Constants for the OpenWrt (luci) integration.""" + +from homeassistant.const import Platform + +DOMAIN = "luci" + +PLATFORMS = [Platform.DEVICE_TRACKER] + +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/luci/coordinator.py b/homeassistant/components/luci/coordinator.py new file mode 100644 index 000000000000..c57f7d0f3b59 --- /dev/null +++ b/homeassistant/components/luci/coordinator.py @@ -0,0 +1,55 @@ +"""DataUpdateCoordinator for the OpenWrt (luci) integration.""" + +from datetime import timedelta +import logging +from typing import Any, override + +from openwrt_luci_rpc import OpenWrtRpc +from openwrt_luci_rpc.exceptions import LuciRpcUnknownError +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + +type LuciConfigEntry = ConfigEntry[LuciCoordinator] + + +class LuciCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for fetching connected devices from an OpenWrt router.""" + + config_entry: LuciConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LuciConfigEntry, + router: OpenWrtRpc, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="luci", + update_interval=SCAN_INTERVAL, + ) + self.router = router + + @override + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the router.""" + try: + result = await self.hass.async_add_executor_job( + lambda: self.router.get_all_connected_devices(only_reachable=True) + ) + except (ConnectionError, RequestsConnectionError, LuciRpcUnknownError) as err: + raise UpdateFailed(f"Error communicating with router: {err}") from err + + _LOGGER.debug("Luci get_all_connected_devices returned: %s", result) + + return {device.mac: device for device in result} diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index 172e6cd5422d..e4f87070c787 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -1,16 +1,16 @@ """Support for OpenWRT (luci) routers.""" import logging -from typing import override +from typing import Any, override -from openwrt_luci_rpc import OpenWrtRpc import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, - DeviceScanner, + AsyncSeeCallback, + ScannerEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -18,14 +18,20 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN +from .coordinator import LuciConfigEntry, LuciCoordinator _LOGGER = logging.getLogger(__name__) -DEFAULT_SSL = False -DEFAULT_VERIFY_SSL = True +PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { @@ -38,73 +44,111 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | None: - """Validate the configuration and return a Luci scanner.""" - scanner = LuciDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) +async def async_setup_scanner( + hass: HomeAssistant, + config: ConfigType, + async_see: AsyncSeeCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> bool: + """Import legacy YAML configuration.""" - return scanner if scanner.success_init else None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: config[CONF_HOST], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_SSL: config.get(CONF_SSL, DEFAULT_SSL), + CONF_VERIFY_SSL: config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + }, + ) - -class LuciDeviceScanner(DeviceScanner): - """Scanner for devices connected to an OpenWrt router.""" - - def __init__(self, config): - """Initialize the scanner.""" - - self.router = OpenWrtRpc( - config[CONF_HOST], - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_SSL], - config[CONF_VERIFY_SSL], - ) - - self.last_results = {} - self.success_init = self.router.is_logged_in() - - @override - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return [device.mac for device in self.last_results] - - @override - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - return next( - (result.hostname for result in self.last_results if result.mac == device), - None, - ) - - @override - def get_extra_attributes(self, device): - """Get extra attributes of a device. - - Some known extra attributes that may be returned in the device tuple - include MAC address (mac), network device (dev), IP address - (ip), reachable status (reachable), associated router - (host), hostname if known (hostname) among others. - """ - if not ( - device := next( - (result for result in self.last_results if result.mac == device), None + if result["type"] is FlowResultType.ABORT: + reason = result["reason"] + if reason in ("invalid_auth", "cannot_connect"): + ir.async_create_issue( + hass, + DOMAIN, + f"yaml_import_{reason}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.ERROR, + translation_key=f"yaml_import_{reason}", + translation_placeholders={"host": config[CONF_HOST]}, ) - ): - return {} - return device._asdict() + return True - def _update_info(self): - """Check the Luci router for devices.""" - result = self.router.get_all_connected_devices(only_reachable=True) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "OpenWrt (luci)", + }, + ) - _LOGGER.debug("Luci get_all_connected_devices returned: %s", result) + return True - self.last_results = [ - device - for device in result - if not hasattr(self.router.router.owrt_version, "release") - or not self.router.router.owrt_version.release - or self.router.router.owrt_version.release[0] < 19 - or device.reachable - ] + +async def async_setup_entry( + hass: HomeAssistant, + entry: LuciConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up device tracker for OpenWrt (luci) component.""" + coordinator = entry.runtime_data + + async_add_entities( + LuciScannerEntity(coordinator, mac, device) + for mac, device in coordinator.data.items() + ) + + +class LuciScannerEntity(CoordinatorEntity[LuciCoordinator], ScannerEntity): + """Representation of a device connected to an OpenWrt router.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LuciCoordinator, + mac: str, + device: Any, + ) -> None: + """Initialize the scanner entity.""" + super().__init__(coordinator) + self._mac = mac + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{mac}" + self._attr_mac_address = mac + self._attr_hostname = device.hostname + self._attr_ip_address = device.ip + self._attr_name = device.hostname or mac + + @property + @override + def unique_id(self) -> str | None: + """Return the unique ID of the entity.""" + return self._attr_unique_id + + @property + @override + def is_connected(self) -> bool: + """Return true if the device is connected to the router.""" + return self._mac in self.coordinator.data + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._mac in self.coordinator.data: + device = self.coordinator.data[self._mac] + self._attr_hostname = device.hostname + self._attr_ip_address = device.ip + self._attr_name = device.hostname or self._mac + super()._handle_coordinator_update() diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index a8df2c63df4b..11f281645750 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -2,7 +2,9 @@ "domain": "luci", "name": "OpenWrt (luci)", "codeowners": ["@mzdrale"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/luci", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], "quality_scale": "legacy", diff --git a/homeassistant/components/luci/strings.json b/homeassistant/components/luci/strings.json new file mode 100644 index 000000000000..d9600a9c9a25 --- /dev/null +++ b/homeassistant/components/luci/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + } + }, + "issues": { + "yaml_import_cannot_connect": { + "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the connection failed.\n\nPlease check that `{host}` is reachable, update your `configuration.yaml` if needed, and restart Home Assistant.", + "title": "YAML configuration import failed: cannot connect" + }, + "yaml_import_invalid_auth": { + "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the credentials are invalid.\n\nPlease update your `configuration.yaml` with valid credentials and restart Home Assistant.", + "title": "YAML configuration import failed: invalid authentication" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e17d12b77ef1..2967780a36cd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -435,6 +435,7 @@ FLOWS = { "london_underground", "lookin", "loqed", + "luci", "luftdaten", "lunatone", "lupusec", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0fb73355adb8..318118aaf05a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5116,7 +5116,7 @@ "integrations": { "luci": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "OpenWrt (luci)" }, diff --git a/tests/components/luci/__init__.py b/tests/components/luci/__init__.py new file mode 100644 index 000000000000..c1710d62777f --- /dev/null +++ b/tests/components/luci/__init__.py @@ -0,0 +1 @@ +"""Tests for the luci integration.""" diff --git a/tests/components/luci/conftest.py b/tests/components/luci/conftest.py new file mode 100644 index 000000000000..0e86c0070345 --- /dev/null +++ b/tests/components/luci/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the luci integration tests.""" + +from collections.abc import Generator +from typing import NamedTuple +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.luci.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from tests.common import MockConfigEntry + + +class MockDevice(NamedTuple): + """Mock device from OpenWrt.""" + + mac: str + hostname: str + ip: str + reachable: bool + host: str + + +MOCK_DEVICE_1 = MockDevice( + mac="AA:BB:CC:DD:EE:FF", + hostname="device1", + ip="192.168.1.100", + reachable=True, + host="192.168.1.1", +) +MOCK_DEVICE_2 = MockDevice( + mac="11:22:33:44:55:66", + hostname="device2", + ip="192.168.1.101", + reachable=True, + host="192.168.1.1", +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.luci.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + data={ + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, + }, + ) + + +@pytest.fixture +def mock_luci_client() -> Generator[MagicMock]: + """Return a mock OpenWrtRpc client.""" + with ( + patch( + "homeassistant.components.luci.coordinator.OpenWrtRpc", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.luci.config_flow.OpenWrtRpc", + new=mock_client_class, + ), + patch( + "homeassistant.components.luci.OpenWrtRpc", + new=mock_client_class, + ), + ): + client = mock_client_class.return_value + client.is_logged_in.return_value = True + client.get_all_connected_devices.return_value = [MOCK_DEVICE_1, MOCK_DEVICE_2] + yield client diff --git a/tests/components/luci/snapshots/test_device_tracker.ambr b/tests/components/luci/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..6730cdea7290 --- /dev/null +++ b/tests/components/luci/snapshots/test_device_tracker.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_device_tracker_setup[device_tracker.device1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.device1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'device1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'device1', + 'platform': 'luci', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_AA:BB:CC:DD:EE:FF', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_setup[device_tracker.device1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'device1', + : 'device1', + : list([ + 'zone.home', + ]), + : '192.168.1.100', + : 'AA:BB:CC:DD:EE:FF', + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.device1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- +# name: test_device_tracker_setup[device_tracker.device2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.device2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'device2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'device2', + 'platform': 'luci', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_11:22:33:44:55:66', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_setup[device_tracker.device2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'device2', + : 'device2', + : list([ + 'zone.home', + ]), + : '192.168.1.101', + : '11:22:33:44:55:66', + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.device2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/luci/test_config_flow.py b/tests/components/luci/test_config_flow.py new file mode 100644 index 000000000000..d21be84c49ff --- /dev/null +++ b/tests/components/luci/test_config_flow.py @@ -0,0 +1,203 @@ +"""Tests for the luci config flow.""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.components.luci.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +REAUTH_INPUT = { + CONF_USERNAME: "root", + CONF_PASSWORD: "new-password", +} + +USER_INPUT = { + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, +} + +# Client configurations that make ``_try_connect`` fail, mapped to the error +# the flow should report. ``cannot_connect`` is a connection error raised by +# the client, ``invalid_auth`` is the client reporting it is not logged in. +CONNECT_ERRORS = [ + ({"side_effect": RequestsConnectionError}, "cannot_connect"), + ({"return_value": False}, "invalid_auth"), +] + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow(hass: HomeAssistant, mock_luci_client: MagicMock) -> None: + """Test the happy path of the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.1" + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_luci_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the user flow aborts when the host is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("client_config", "base_error"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_errors( + hass: HomeAssistant, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + base_error: str, +) -> None: + """Test the user flow shows errors and recovers on retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_luci_client.is_logged_in.configure_mock(**client_config) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + mock_luci_client.is_logged_in.configure_mock(side_effect=None, return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow(hass: HomeAssistant, mock_luci_client: MagicMock) -> None: + """Test the happy path of the import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.1" + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_luci_client") +async def test_import_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the import flow aborts when the host is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("client_config", "reason"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow_errors( + hass: HomeAssistant, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + reason: str, +) -> None: + """Test the import flow aborts when connecting fails.""" + mock_luci_client.is_logged_in.configure_mock(**client_config) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test the happy path of the reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize(("client_config", "base_error"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + base_error: str, +) -> None: + """Test the reauth flow shows errors and recovers on retry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + mock_luci_client.is_logged_in.configure_mock(**client_config) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": base_error} + + mock_luci_client.is_logged_in.configure_mock(side_effect=None, return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/luci/test_device_tracker.py b/tests/components/luci/test_device_tracker.py new file mode 100644 index 000000000000..7340f8acefc1 --- /dev/null +++ b/tests/components/luci/test_device_tracker.py @@ -0,0 +1,60 @@ +"""Tests for the luci device tracker.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_DEVICE_2 + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_luci_client") +async def test_device_tracker_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device tracker entities are created.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_disconnect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device goes not_home when disconnected.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.device1") + assert state is not None + assert state.state == STATE_HOME + + # Simulate device disconnecting + mock_luci_client.get_all_connected_devices.return_value = [MOCK_DEVICE_2] + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.device1") + assert state is not None + assert state.state == STATE_NOT_HOME diff --git a/tests/components/luci/test_init.py b/tests/components/luci/test_init.py new file mode 100644 index 000000000000..240126c9827b --- /dev/null +++ b/tests/components/luci/test_init.py @@ -0,0 +1,110 @@ +"""Tests for the luci integration.""" + +from unittest.mock import MagicMock + +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.luci.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +YAML_CONFIG = { + DEVICE_TRACKER_DOMAIN: { + CONF_PLATFORM: DOMAIN, + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + } +} + + +@pytest.mark.usefixtures("mock_luci_client") +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_setup_entry_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test setup fails with ConfigEntryNotReady on connection error.""" + mock_luci_client.is_logged_in.side_effect = RequestsConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test setup fails with ConfigEntryAuthFailed and starts a reauth flow.""" + mock_luci_client.is_logged_in.return_value = False + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.usefixtures("mock_device_tracker_conf") +async def test_yaml_import_invalid_auth( + hass: HomeAssistant, + mock_luci_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing YAML config creates an issue on invalid auth.""" + mock_luci_client.is_logged_in.return_value = False + + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_import_invalid_auth") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"host": "192.168.1.1"} + + +@pytest.mark.usefixtures("mock_device_tracker_conf") +async def test_yaml_import_cannot_connect( + hass: HomeAssistant, + mock_luci_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing YAML config creates an issue on connection failure.""" + mock_luci_client.is_logged_in.side_effect = RequestsConnectionError + + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_import_cannot_connect") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"host": "192.168.1.1"} From 079be5de66d5ebe7362ce0981ceefad997968a78 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 9 Jul 2026 00:18:42 +1000 Subject: [PATCH 257/707] Fix Teslemetry steering-wheel-heat select IndexError on out-of-range level (#175815) --- homeassistant/components/teslemetry/select.py | 19 ++-- tests/components/teslemetry/test_select.py | 90 ++++++++++++++++++- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index f6bba223b7cf..3601c2d353ff 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -289,10 +289,14 @@ class TeslemetryVehiclePollingSelectEntity( def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" self._climate = bool(self.get("climate_state_is_climate_on")) - if not isinstance(self._value, int): - self._attr_current_option = None + value = self._value + # Defensive clamp: Tesla could report a level outside the modeled + # range, so map it to the nearest known option rather than erroring. + if isinstance(value, int): + options = self.entity_description.options + self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: - self._attr_current_option = self.entity_description.options[self._value] + self._attr_current_option = None class TeslemetryStreamingSelectEntity( @@ -336,10 +340,13 @@ class TeslemetryStreamingSelectEntity( def _value_callback(self, value: int | None) -> None: """Update the value of the entity.""" - if value is None: - self._attr_current_option = None + # Defensive clamp: Tesla could report a level outside the modeled + # range, so map it to the nearest known option rather than erroring. + if isinstance(value, int): + options = self.entity_description.options + self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: - self._attr_current_option = self.entity_description.options[value] + self._attr_current_option = None self.async_write_ha_state() def _climate_callback(self, value: bool | None) -> None: diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index 7c2ec7a34f6d..b9b893652206 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -1,5 +1,6 @@ """Test the Teslemetry select platform.""" +from collections.abc import Awaitable, Callable from copy import deepcopy from unittest.mock import AsyncMock, patch @@ -15,15 +16,25 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL -from homeassistant.components.teslemetry.select import LEVEL, LOW, MEDIUM, OFF +from homeassistant.components.teslemetry.coordinator import ( + ENERGY_INFO_INTERVAL, + VEHICLE_INTERVAL, +) +from homeassistant.components.teslemetry.select import HIGH, LEVEL, LOW, MEDIUM, OFF from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import assert_entities, reload_platform, setup_platform -from .const import COMMAND_ERRORS, COMMAND_OK, METADATA, SITE_INFO, VEHICLE_DATA_ALT +from .const import ( + COMMAND_ERRORS, + COMMAND_OK, + METADATA, + METADATA_LEGACY, + SITE_INFO, + VEHICLE_DATA_ALT, +) from tests.common import async_fire_time_changed @@ -348,6 +359,79 @@ async def test_select_streaming( assert hass.states.get("select.test_steering_wheel_heater").state == "off" +async def _drive_polling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + value: int, +) -> None: + """Push a steering wheel level through the polling path.""" + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["steering_wheel_heat_level"] = value + mock_vehicle_data.return_value = data + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + + +async def _drive_streaming( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + value: int, +) -> None: + """Push a steering wheel level through the streaming path.""" + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": {Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: value}, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + + +@pytest.mark.parametrize( + ("metadata", "driver"), + [ + pytest.param(METADATA_LEGACY, _drive_polling, id="polling"), + pytest.param(METADATA, _drive_streaming, id="streaming"), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(2, HIGH, id="level_2_clamped"), + pytest.param(3, HIGH, id="level_3_clamped"), + ], +) +async def test_steering_wheel_heat_levels( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_metadata: AsyncMock, + mock_add_listener: AsyncMock, + metadata: dict, + driver: Callable[ + [HomeAssistant, FrozenDateTimeFactory, AsyncMock, AsyncMock, int], + Awaitable[None], + ], + value: int, + expected: str, +) -> None: + """A level beyond the last modeled option clamps to that option, high.""" + freezer.move_to("2024-01-01 00:00:00+00:00") + mock_metadata.return_value = metadata + + await setup_platform(hass, [Platform.SELECT]) + + await driver(hass, freezer, mock_vehicle_data, mock_add_listener, value) + await hass.async_block_till_done() + + state = hass.states.get("select.test_steering_wheel_heater") + assert state.state == expected + + async def test_export_rule_restore( hass: HomeAssistant, mock_site_info: AsyncMock, From 204bab772fbb399a6edac9d381c3e6fa86390c34 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Wed, 8 Jul 2026 16:21:20 +0200 Subject: [PATCH 258/707] Rename netatmo data_handler to coordinator (#175868) --- homeassistant/components/netatmo/__init__.py | 2 +- homeassistant/components/netatmo/binary_sensor.py | 2 +- homeassistant/components/netatmo/button.py | 2 +- homeassistant/components/netatmo/camera.py | 2 +- homeassistant/components/netatmo/climate.py | 2 +- homeassistant/components/netatmo/config_flow.py | 2 +- .../netatmo/{data_handler.py => coordinator.py} | 0 homeassistant/components/netatmo/cover.py | 2 +- homeassistant/components/netatmo/diagnostics.py | 2 +- homeassistant/components/netatmo/entity.py | 2 +- homeassistant/components/netatmo/fan.py | 2 +- homeassistant/components/netatmo/light.py | 2 +- homeassistant/components/netatmo/media_source.py | 2 +- homeassistant/components/netatmo/select.py | 2 +- homeassistant/components/netatmo/sensor.py | 2 +- homeassistant/components/netatmo/services.py | 2 +- homeassistant/components/netatmo/switch.py | 2 +- homeassistant/components/netatmo/webhook.py | 2 +- tests/components/netatmo/common.py | 2 +- tests/components/netatmo/test_binary_sensor.py | 6 +++--- tests/components/netatmo/test_camera.py | 8 ++++---- tests/components/netatmo/test_init.py | 8 ++++---- tests/components/netatmo/test_light.py | 2 +- 23 files changed, 30 insertions(+), 30 deletions(-) rename homeassistant/components/netatmo/{data_handler.py => coordinator.py} (100%) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index b1d591190ff0..183732c5ea1c 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -30,7 +30,7 @@ from homeassistant.helpers.typing import ConfigType from . import api from .const import DOMAIN, PLATFORMS -from .data_handler import NetatmoConfigEntry, NetatmoDataHandler +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler from .services import async_setup_services from .webhook import async_register_webhook, async_unregister_webhook diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index d0dc4348690a..f230a33c8e8e 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -37,7 +37,7 @@ from .const import ( NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_WEATHER_BINARY_SENSOR, ) -from .data_handler import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index dd0895a28157..8c7b536bf8fb 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 51c14e0b69d3..692149f34a2f 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -37,7 +37,7 @@ from .const import ( SERVICE_SET_PERSONS_HOME, WEBHOOK_PUSH_TYPE, ) -from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 7c60ea7f2f0f..4e50972286c5 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -50,7 +50,7 @@ from .const import ( SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom from .entity import NetatmoRoomEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 0883cf89d2ac..0e4c74a6d000 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -24,7 +24,7 @@ from .const import ( CONF_WEATHER_AREAS, DOMAIN, ) -from .data_handler import NetatmoConfigEntry +from .coordinator import NetatmoConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/coordinator.py similarity index 100% rename from homeassistant/components/netatmo/data_handler.py rename to homeassistant/components/netatmo/coordinator.py diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index ba244724f736..2244de089830 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -16,7 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index a3b4f032714d..08bbd0a1ff89 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from .data_handler import ACCOUNT, NetatmoConfigEntry +from .coordinator import ACCOUNT, NetatmoConfigEntry TO_REDACT = { "access_token", diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index d74658eb0727..c1f386b888dc 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -20,7 +20,7 @@ from .const import ( DOMAIN, SIGNAL_NAME, ) -from .data_handler import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom +from .coordinator import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom class NetatmoBaseEntity(Entity): diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 3dacf5b3fe5f..c5e01c5904a6 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 3bbc6f320c9e..2de7dbcf7ee6 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -19,7 +19,7 @@ from .const import ( NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_LIGHT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 5bfaf25f4739..2006e43a94a2 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from .const import DOMAIN, MANUFACTURER -from .data_handler import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry _LOGGER = logging.getLogger(__name__) MIME_TYPE = "application/x-mpegURL" diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index d4d746b62fbd..32c9fe74ee61 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -16,7 +16,7 @@ from .const import ( MANUFACTURER, NETATMO_CREATE_SELECT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 24bc7e149bd2..2b1f9bc90593 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -53,7 +53,7 @@ from .const import ( NETATMO_CREATE_WEATHER_SENSOR, SIGNAL_NAME, ) -from .data_handler import ( +from .coordinator import ( HOME, PUBLIC, NetatmoConfigEntry, diff --git a/homeassistant/components/netatmo/services.py b/homeassistant/components/netatmo/services.py index b3ca63824a74..1265b70fa665 100644 --- a/homeassistant/components/netatmo/services.py +++ b/homeassistant/components/netatmo/services.py @@ -5,7 +5,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .data_handler import NetatmoConfigEntry, async_get_loaded_entry +from .coordinator import NetatmoConfigEntry, async_get_loaded_entry from .webhook import async_register_webhook, async_unregister_webhook SERVICE_REGISTER_WEBHOOK = "register_webhook" diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 7bb3f83387ef..351d6d005fed 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 1670f2c2657c..b5cd935abdcb 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -37,7 +37,7 @@ from .const import ( WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import NetatmoConfigEntry, NetatmoDataHandler +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 1dc0858909cf..8b8b60559148 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -121,7 +121,7 @@ async def simulate_webhook(hass: HomeAssistant, webhook_id: str, response) -> No def selected_platforms(platforms: list[Platform]) -> Iterator[None]: """Restrict loaded platforms to list given.""" with ( - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", platforms), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", platforms), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py index 8e9988c309d2..65b8b53e680b 100644 --- a/tests/components/netatmo/test_binary_sensor.py +++ b/tests/components/netatmo/test_binary_sensor.py @@ -59,7 +59,7 @@ async def test_doortag_setup( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( @@ -171,7 +171,7 @@ async def test_doortag_opening_status_change( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( @@ -298,7 +298,7 @@ async def test_doortag_opening_category( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 7ccbe041f663..1bc1542f23db 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -474,7 +474,7 @@ async def test_camera_reconnect_webhook( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -587,7 +587,7 @@ async def test_camera_webhook_consistency( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -707,7 +707,7 @@ async def test_setup_component_no_devices( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -750,7 +750,7 @@ async def test_camera_image_raises_exception( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index c6f45b129287..d97ac9fd641c 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -129,7 +129,7 @@ async def test_setup_component_with_config( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["sensor"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["sensor"]), ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -301,7 +301,7 @@ async def test_setup_with_cloud( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", []), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -371,7 +371,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", []), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -427,7 +427,7 @@ async def test_setup_component_with_delay( "pyatmo.AbstractAsyncAuth.async_post_api_request", side_effect=partial(fake_post_request, hass), ) as mock_post_api_request, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]), ): assert await async_setup_component( hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 9641519d4c04..4d3d339e4fe7 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -127,7 +127,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), From 4c9189ec2f6f9518704a9abcb72e5f5d506d29fb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:22:04 +0200 Subject: [PATCH 259/707] Use state attribute enums in Tesla Fleet (#175969) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/tesla_fleet/device_tracker.py | 13 ++++++++++--- homeassistant/components/tesla_fleet/sensor.py | 8 +++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 4b4df4754216..986c342c50a6 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -2,7 +2,10 @@ from typing import override -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,8 +55,12 @@ class TeslaFleetDeviceTrackerEntity( and self._attr_latitude is None and self._attr_longitude is None ): - self._attr_latitude = state.attributes.get("latitude") - self._attr_longitude = state.attributes.get("longitude") + self._attr_latitude = state.attributes.get( + TrackerEntityStateAttribute.LATITUDE + ) + self._attr_longitude = state.attributes.get( + TrackerEntityStateAttribute.LONGITUDE + ) class TeslaFleetDeviceTrackerLocationEntity(TeslaFleetDeviceTrackerEntity): diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index a4d08e49c6b6..fa8ccaf9c974 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorEntityStateAttribute, SensorStateClass, ) from homeassistant.const import ( @@ -526,7 +527,12 @@ class TeslaFleetVehicleSensorEntity(TeslaFleetVehicleEntity, RestoreSensor): if ( self.entity_description.key in CHARGE_ENERGY_RESET_KEYS and (last_state := await self.async_get_last_state()) is not None - and (last_reset := last_state.attributes.get("last_reset")) is not None + and ( + last_reset := last_state.attributes.get( + SensorEntityStateAttribute.LAST_RESET + ) + ) + is not None ): self._attr_last_reset = dt_util.parse_datetime(str(last_reset)) From acdcc83fbab112b9797a0a203ab96bd07c51bffc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:22:22 +0200 Subject: [PATCH 260/707] Use state attribute enums in temperature (#175967) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/temperature/condition.py | 21 ++++++++++--------- .../components/temperature/trigger.py | 21 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py index 9b407851eb64..a793108de64d 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -3,20 +3,19 @@ from typing import override from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, + ClimateEntityStateAttribute, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.components.water_heater import ( - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterStateAttribute, ) from homeassistant.components.weather import ( - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_TEMPERATURE_UNIT, DOMAIN as WEATHER_DOMAIN, + WeatherEntityStateAttribute, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature +from homeassistant.const import EntityStateAttribute, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( @@ -27,16 +26,16 @@ from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = { CLIMATE_DOMAIN: DomainSpec( - value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, + value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE, ), SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), WATER_HEATER_DOMAIN: DomainSpec( - value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE, ), WEATHER_DOMAIN: DomainSpec( - value_source=ATTR_WEATHER_TEMPERATURE, + value_source=WeatherEntityStateAttribute.TEMPERATURE, ), } @@ -68,9 +67,11 @@ class TemperatureCondition(EntityNumericalConditionWithUnitBase): def _get_entity_unit(self, entity_state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if entity_state.domain == SENSOR_DOMAIN: - return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if entity_state.domain == WEATHER_DOMAIN: - return entity_state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT) + return entity_state.attributes.get( + WeatherEntityStateAttribute.TEMPERATURE_UNIT + ) # Climate and water_heater: show_temp converts to system unit return self._hass.config.units.temperature_unit diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index a1022422b207..dd7d6de43845 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -3,20 +3,19 @@ from typing import override from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, + ClimateEntityStateAttribute, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.components.water_heater import ( - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterStateAttribute, ) from homeassistant.components.weather import ( - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_TEMPERATURE_UNIT, DOMAIN as WEATHER_DOMAIN, + WeatherEntityStateAttribute, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature +from homeassistant.const import EntityStateAttribute, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -29,14 +28,16 @@ from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = { CLIMATE_DOMAIN: DomainSpec( - value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, + value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE, ), SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), - WATER_HEATER_DOMAIN: DomainSpec(value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE), + WATER_HEATER_DOMAIN: DomainSpec( + value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE + ), WEATHER_DOMAIN: DomainSpec( - value_source=ATTR_WEATHER_TEMPERATURE, + value_source=WeatherEntityStateAttribute.TEMPERATURE, ), } @@ -70,9 +71,9 @@ class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase): def _get_entity_unit(self, state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if state.domain == SENSOR_DOMAIN: - return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if state.domain == WEATHER_DOMAIN: - return state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT) + return state.attributes.get(WeatherEntityStateAttribute.TEMPERATURE_UNIT) # Climate and water_heater: show_temp converts to system unit return self._hass.config.units.temperature_unit From ba9445dde8e788fe55389fb3f3528e222635185e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:23:30 +0200 Subject: [PATCH 261/707] Use ClimateEntityStateAttribute enum in SwitchBot Cloud (#175962) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/switchbot_cloud/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 0b5bc94ef8b7..971dab2d94eb 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -15,7 +15,6 @@ from switchbot_api import ( from homeassistant.components import climate as FanState from homeassistant.components.climate import ( - ATTR_FAN_MODE, ATTR_TEMPERATURE, PRESET_BOOST, PRESET_COMFORT, @@ -24,6 +23,7 @@ from homeassistant.components.climate import ( PRESET_NONE, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACMode, ) from homeassistant.const import ( @@ -128,10 +128,10 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity, RestoreE _LOGGER.debug("Last state attributes: %s", last_state.attributes) self._attr_hvac_mode = HVACMode(last_state.state) self._attr_fan_mode = last_state.attributes.get( - ATTR_FAN_MODE, self._attr_fan_mode + ClimateEntityStateAttribute.FAN_MODE, self._attr_fan_mode ) self._attr_target_temperature = last_state.attributes.get( - ATTR_TEMPERATURE, self._attr_target_temperature + ClimateEntityStateAttribute.TEMPERATURE, self._attr_target_temperature ) def _get_mode(self, hvac_mode: HVACMode | None) -> int: From 5b0ac6706dcfd5b4cb2346be2814da82870e478d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:23:48 +0200 Subject: [PATCH 262/707] Use ClimateEntityStateAttribute enum in ScreenLogic (#175959) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/screenlogic/climate.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index bb40f3cfe2db..19c390c83ab5 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -11,10 +11,10 @@ from screenlogicpy.device_const.heat import HEAT_MODE from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.climate import ( - ATTR_PRESET_MODE, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -212,9 +212,12 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): prev_state = await self.async_get_last_state() if ( prev_state is not None - and prev_state.attributes.get(ATTR_PRESET_MODE) is not None + and prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + is not None ): - mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE)) + mode = HEAT_MODE.parse( + prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + ) _LOGGER.debug( "Startup setting last_preset to %s from prev_state", mode.name, From 2be826bb4edbc97d98ff9dfc49c3fdbde9cbe90b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:24:32 +0200 Subject: [PATCH 263/707] Use EntityStateAttribute enum in statistics (#175957) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/statistics/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 688d999a3c64..23c595e16d7e 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -24,14 +24,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -841,7 +840,9 @@ class StatisticsSensor(SensorEntity): state characteristics. """ - base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + base_unit: str | None = new_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) unit: str | None = None stat_type = self._state_characteristic if self.is_binary and stat_type in STATS_BINARY_PERCENTAGE: @@ -880,7 +881,7 @@ class StatisticsSensor(SensorEntity): if stat_type in STATS_DATETIME: return SensorDeviceClass.TIMESTAMP if stat_type in STATS_NUMERIC_RETAIN_UNIT: - device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + device_class = new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class is None: return None if ( From 8956f02c58a0bdb1fae105fa4ea35019370a3918 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:24:42 +0200 Subject: [PATCH 264/707] Use ScheduleEntityStateAttribute enum in schedule triggers (#175958) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/schedule/trigger.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schedule/trigger.py b/homeassistant/components/schedule/trigger.py index dba6c29b99bd..f637b60433e2 100644 --- a/homeassistant/components/schedule/trigger.py +++ b/homeassistant/components/schedule/trigger.py @@ -11,7 +11,7 @@ from homeassistant.helpers.trigger import ( make_entity_target_state_trigger, ) -from .const import ATTR_NEXT_EVENT, DOMAIN +from .const import DOMAIN, ScheduleEntityStateAttribute class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): @@ -24,8 +24,10 @@ class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): @override def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check that the origin matches and the next event changed.""" - from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT) - to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT) + from_next_event = from_state.attributes.get( + ScheduleEntityStateAttribute.NEXT_EVENT + ) + to_next_event = to_state.attributes.get(ScheduleEntityStateAttribute.NEXT_EVENT) return ( from_state.state in self._from_states and from_next_event != to_next_event From e7b5065be7d373b5abe64c0ec895627c8c94d47f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:25:30 +0200 Subject: [PATCH 265/707] Use EntityStateAttribute enum in Space API (#175956) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/spaceapi/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 106ce1b87192..a05867b8fd7f 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -15,7 +15,6 @@ from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, CONF_EMAIL, CONF_ENTITY_ID, @@ -23,6 +22,7 @@ from homeassistant.const import ( CONF_SENSORS, CONF_STATE, CONF_URL, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -280,8 +280,10 @@ class APISpaceApiView(HomeAssistantView): else: sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE] # Some sensors don't have a unit of measurement - if ATTR_UNIT_OF_MEASUREMENT in sensor_state.attributes: - sensor_data[ATTR_UNIT] = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + if EntityStateAttribute.UNIT_OF_MEASUREMENT in sensor_state.attributes: + sensor_data[ATTR_UNIT] = sensor_state.attributes[ + EntityStateAttribute.UNIT_OF_MEASUREMENT + ] return sensor_data From 193550be50e90944f763ee2f7b1adcd70b7506b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:26:10 +0200 Subject: [PATCH 266/707] Use LightEntityStateAttribute enum in RFLink (#175954) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/rflink/light.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 58dc85f1fc12..dd15a21e5bcc 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, + LightEntityStateAttribute, ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant @@ -215,10 +216,13 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity): old_state = await self.async_get_last_state() if ( old_state is not None - and old_state.attributes.get(ATTR_BRIGHTNESS) is not None + and old_state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) + is not None ): # restore also brightness in dimmables devices - self._brightness = int(old_state.attributes[ATTR_BRIGHTNESS]) + self._brightness = int( + old_state.attributes[LightEntityStateAttribute.BRIGHTNESS] + ) @override async def async_turn_on(self, **kwargs: Any) -> None: From 7eca76f50b52d3256381947db0e7ea6d7d9693fd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:31:00 +0200 Subject: [PATCH 267/707] Use state attribute enums in Prometheus (#175952) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/prometheus/__init__.py | 142 ++++++++---------- 1 file changed, 66 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 2904d9320f10..d1ba2dede5d7 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -15,54 +15,32 @@ import voluptuous as vol from homeassistant import core as hacore from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, - ATTR_FAN_MODES, - ATTR_HVAC_ACTION, - ATTR_HVAC_MODES, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, + ClimateEntityCapabilityAttribute, + ClimateEntityStateAttribute, HVACAction, ) -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, -) +from homeassistant.components.cover import CoverEntityStateAttribute from homeassistant.components.fan import ( - ATTR_DIRECTION, - ATTR_OSCILLATING, - ATTR_PERCENTAGE, - ATTR_PRESET_MODE, - ATTR_PRESET_MODES, DIRECTION_FORWARD, DIRECTION_REVERSE, + FanEntityCapabilityAttribute, + FanEntityStateAttribute, ) from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.humidifier import ( + HumidifierEntityCapabilityAttribute, + HumidifierEntityStateAttribute, +) +from homeassistant.components.light import LightEntityStateAttribute from homeassistant.components.sensor import SensorDeviceClass - -# Alias water_heater constants to avoid name clashes with -# similarly named climate constants from homeassistant.components.water_heater import ( - ATTR_AWAY_MODE as WATER_HEATER_ATTR_AWAY_MODE, - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, - ATTR_MAX_TEMP as WATER_HEATER_ATTR_MAX_TEMP, - ATTR_MIN_TEMP as WATER_HEATER_ATTR_MIN_TEMP, - ATTR_OPERATION_LIST as WATER_HEATER_ATTR_OPERATION_LIST, - ATTR_OPERATION_MODE as WATER_HEATER_ATTR_OPERATION_MODE, - ATTR_TARGET_TEMP_HIGH as WATER_HEATER_ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW as WATER_HEATER_ATTR_TARGET_TEMP_LOW, + WaterHeaterCapabilityAttribute, + WaterHeaterStateAttribute, ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_MODE, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, CONTENT_TYPE_TEXT_PLAIN, EVENT_STATE_CHANGED, PERCENTAGE, @@ -73,6 +51,7 @@ from homeassistant.const import ( STATE_OPENING, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfLength, UnitOfTemperature, ) @@ -293,8 +272,8 @@ class PrometheusMetrics: if ( old_state := event.data.get("old_state") ) is not None and old_state.attributes.get( - ATTR_FRIENDLY_NAME - ) != state.attributes.get(ATTR_FRIENDLY_NAME): + EntityStateAttribute.FRIENDLY_NAME + ) != state.attributes.get(EntityStateAttribute.FRIENDLY_NAME): self._remove_labelsets(old_state.entity_id) self.handle_state(state) @@ -569,7 +548,10 @@ class PrometheusMetrics: def state_as_number(state: State) -> float | None: """Return state as a float, or None if state cannot be converted.""" try: - if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: + if ( + state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + == SensorDeviceClass.TIMESTAMP + ): value = as_timestamp(state.state) else: value = state_helper.state_as_number(state) @@ -588,7 +570,7 @@ class PrometheusMetrics: labels = { "entity": state.entity_id, "domain": state.domain, - "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME), + "friendly_name": state.attributes.get(EntityStateAttribute.FRIENDLY_NAME), } if not labels.keys().isdisjoint(extra_labels.keys()): conflicting_keys = labels.keys() & extra_labels.keys() @@ -731,7 +713,9 @@ class PrometheusMetrics: if (value := self.state_as_number(state)) is None: return - if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + if unit := self._unit_string( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ): metric = self._metric( f"{domain}_state_{unit}", prometheus_client.Gauge, @@ -747,7 +731,7 @@ class PrometheusMetrics: ) if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT ): value = TemperatureConverter.convert( @@ -777,7 +761,7 @@ class PrometheusMetrics: def _handle_geo_location(self, state: State) -> None: labels = self._labels(state, {"source": state.attributes.get("source", "")}) if (value := self.state_as_number(state)) is not None: - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if unit is not None: value = DistanceConverter.convert(value, unit, UnitOfLength.METERS) self._metric( @@ -815,13 +799,13 @@ class PrometheusMetrics: ) self._float_metric( state, - ATTR_CURRENT_POSITION, + CoverEntityStateAttribute.CURRENT_POSITION, "cover_position", "Position of the cover (0-100)", ) self._float_metric( state, - ATTR_CURRENT_TILT_POSITION, + CoverEntityStateAttribute.CURRENT_TILT_POSITION, "cover_tilt_position", "Tilt Position of the cover (0-100)", ) @@ -830,7 +814,7 @@ class PrometheusMetrics: if (value := self.state_as_number(state)) is None: return - brightness = state.attributes.get(ATTR_BRIGHTNESS) + brightness = state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) if state.state == STATE_ON and brightness is not None: value = float(brightness) / 255.0 value = value * 100 @@ -845,25 +829,25 @@ class PrometheusMetrics: def _handle_climate(self, state: State) -> None: self._temperature_metric( state, - ATTR_TEMPERATURE, + ClimateEntityStateAttribute.TEMPERATURE, "climate_target_temperature_celsius", "Target temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_TARGET_TEMP_HIGH, + ClimateEntityStateAttribute.TARGET_TEMP_HIGH, "climate_target_temperature_high_celsius", "Target high temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_TARGET_TEMP_LOW, + ClimateEntityStateAttribute.TARGET_TEMP_LOW, "climate_target_temperature_low_celsius", "Target low temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_CURRENT_TEMPERATURE, + ClimateEntityStateAttribute.CURRENT_TEMPERATURE, "climate_current_temperature_celsius", "Current temperature in degrees Celsius", ) @@ -871,7 +855,7 @@ class PrometheusMetrics: self._enum_metric( state, ( - (attr := state.attributes.get(ATTR_HVAC_ACTION)) + (attr := state.attributes.get(ClimateEntityStateAttribute.HVAC_ACTION)) and getattr(attr, "value", attr) ), [action.value for action in HVACAction], @@ -882,23 +866,23 @@ class PrometheusMetrics: self._enum_metric( state, state.state, - state.attributes.get(ATTR_HVAC_MODES), + state.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES), "climate_mode", "HVAC mode", "mode", ) self._enum_metric( state, - state.attributes.get(ATTR_PRESET_MODE), - state.attributes.get(ATTR_PRESET_MODES), + state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE), + state.attributes.get(ClimateEntityCapabilityAttribute.PRESET_MODES), "climate_preset_mode", "Preset mode enum", "mode", ) self._enum_metric( state, - state.attributes.get(ATTR_FAN_MODE), - state.attributes.get(ATTR_FAN_MODES), + state.attributes.get(ClimateEntityStateAttribute.FAN_MODE), + state.attributes.get(ClimateEntityCapabilityAttribute.FAN_MODES), "climate_fan_mode", "Fan mode enum", "mode", @@ -909,15 +893,15 @@ class PrometheusMetrics: self._float_metric( state, - ATTR_HUMIDITY, + HumidifierEntityStateAttribute.HUMIDITY, "humidifier_target_humidity_percent", "Target Relative Humidity", ) self._enum_metric( state, - state.attributes.get(ATTR_MODE), - state.attributes.get(ATTR_AVAILABLE_MODES), + state.attributes.get(HumidifierEntityStateAttribute.MODE), + state.attributes.get(HumidifierEntityCapabilityAttribute.AVAILABLE_MODES), "humidifier_mode", "Humidifier Mode", "mode", @@ -927,44 +911,45 @@ class PrometheusMetrics: # Temperatures self._temperature_metric( state, - ATTR_TEMPERATURE, + WaterHeaterStateAttribute.TEMPERATURE, "water_heater_temperature_celsius", "Target temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + WaterHeaterStateAttribute.CURRENT_TEMPERATURE, "water_heater_current_temperature_celsius", "Current temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_TARGET_TEMP_HIGH, + WaterHeaterStateAttribute.TARGET_TEMP_HIGH, "water_heater_target_temperature_high_celsius", "Target high temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_TARGET_TEMP_LOW, + WaterHeaterStateAttribute.TARGET_TEMP_LOW, "water_heater_target_temperature_low_celsius", "Target low temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_MIN_TEMP, + WaterHeaterCapabilityAttribute.MIN_TEMP, "water_heater_min_temperature_celsius", "Minimum allowed temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_MAX_TEMP, + WaterHeaterCapabilityAttribute.MAX_TEMP, "water_heater_max_temperature_celsius", "Maximum allowed temperature in degrees Celsius", ) self._enum_metric( state, - state.attributes.get(WATER_HEATER_ATTR_OPERATION_MODE) or state.state, - state.attributes.get(WATER_HEATER_ATTR_OPERATION_LIST), + state.attributes.get(WaterHeaterStateAttribute.OPERATION_MODE) + or state.state, + state.attributes.get(WaterHeaterCapabilityAttribute.OPERATION_LIST), "water_heater_operation_mode", "Water heater operation mode", "mode", @@ -973,7 +958,7 @@ class PrometheusMetrics: # Away mode bool self._bool_metric( state, - WATER_HEATER_ATTR_AWAY_MODE, + WaterHeaterStateAttribute.AWAY_MODE, "water_heater_away_mode", "Whether away mode is on (0/1)", {STATE_ON}, @@ -986,29 +971,32 @@ class PrometheusMetrics: def _handle_fan(self, state: State) -> None: self._numeric_metric(state, "fan", "fan") self._float_metric( - state, ATTR_PERCENTAGE, "fan_speed_percent", "Fan speed percent (0-100)" + state, + FanEntityStateAttribute.PERCENTAGE, + "fan_speed_percent", + "Fan speed percent (0-100)", ) self._bool_metric( state, - ATTR_OSCILLATING, + FanEntityStateAttribute.OSCILLATING, "fan_is_oscillating", "Whether the fan is oscillating (0/1)", ) self._enum_metric( state, - state.attributes.get(ATTR_PRESET_MODE), - state.attributes.get(ATTR_PRESET_MODES), + state.attributes.get(FanEntityStateAttribute.PRESET_MODE), + state.attributes.get(FanEntityCapabilityAttribute.PRESET_MODES), "fan_preset_mode", "Fan preset mode enum", "mode", ) - fan_direction = state.attributes.get(ATTR_DIRECTION) + fan_direction = state.attributes.get(FanEntityStateAttribute.DIRECTION) if fan_direction in {DIRECTION_FORWARD, DIRECTION_REVERSE}: self._bool_metric( state, - ATTR_DIRECTION, + FanEntityStateAttribute.DIRECTION, "fan_direction_reversed", "Fan direction reversed (bool)", {DIRECTION_REVERSE}, @@ -1050,7 +1038,9 @@ class PrometheusMetrics: ) def _handle_sensor(self, state: State) -> None: - unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + unit = self._unit_string( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ) for metric_handler in self._sensor_metric_handlers: metric = metric_handler(state, unit) @@ -1063,7 +1053,7 @@ class PrometheusMetrics: documentation = f"Sensor data measured in {unit}" if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT ): value = TemperatureConverter.convert( @@ -1085,7 +1075,7 @@ class PrometheusMetrics: @staticmethod def _sensor_attribute_metric(state: State, unit: str | None) -> str | None: """Get metric based on device class attribute.""" - metric = state.attributes.get(ATTR_DEVICE_CLASS) + metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if metric is not None: return f"sensor_{metric}_{unit}" return None @@ -1096,7 +1086,7 @@ class PrometheusMetrics: These have no unit of measurement attribute. """ - metric = state.attributes.get(ATTR_DEVICE_CLASS) + metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if metric == SensorDeviceClass.TIMESTAMP: return f"sensor_{metric}_seconds" return None From 8e598988bbf8f762c5da02331d5a6d2063e6e9b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 8 Jul 2026 16:31:39 +0200 Subject: [PATCH 268/707] Show proper error message if Google Health API is disabled (#175994) --- .../components/google_health/config_flow.py | 13 ++++- .../components/google_health/const.py | 4 ++ .../components/google_health/strings.json | 1 + .../google_health/test_config_flow.py | 50 ++++++++++++++++++- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_health/config_flow.py b/homeassistant/components/google_health/config_flow.py index 25d21a36ac20..4450e44e6fe7 100644 --- a/homeassistant/components/google_health/config_flow.py +++ b/homeassistant/components/google_health/config_flow.py @@ -6,14 +6,17 @@ from typing import Any, override from google_health_api import GoogleHealthApi from google_health_api.const import HealthApiScope -from google_health_api.exceptions import GoogleHealthApiError +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .api import SimpleAuth -from .const import DEFAULT_TITLE, DOMAIN, OAUTH_SCOPES +from .const import API_CONSOLE_URL, DEFAULT_TITLE, DOMAIN, OAUTH_SCOPES _LOGGER = logging.getLogger(__name__) @@ -68,6 +71,12 @@ class OAuth2FlowHandler( try: identity = await api.get_identity() + except HealthApiForbiddenException as err: + _LOGGER.error("Error getting Google Health identity: %s", err) + return self.async_abort( + reason="api_not_enabled", + description_placeholders={"url": API_CONSOLE_URL}, + ) except GoogleHealthApiError as err: _LOGGER.error("Error getting Google Health identity: %s", err) return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/google_health/const.py b/homeassistant/components/google_health/const.py index 03243729ae26..fc4660cc0695 100644 --- a/homeassistant/components/google_health/const.py +++ b/homeassistant/components/google_health/const.py @@ -7,6 +7,10 @@ DOMAIN = "google_health" OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" +API_CONSOLE_URL = ( + "https://console.developers.google.com/apis/api/health.googleapis.com/overview" +) + DEFAULT_TITLE = "Google Health" OAUTH_SCOPES = [ diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json index 3921b4deaa84..8159cafe0444 100644 --- a/homeassistant/components/google_health/strings.json +++ b/homeassistant/components/google_health/strings.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "api_not_enabled": "The Google Health API is not enabled for your Google Cloud project. Enable it in the [Google Cloud Console]({url}), wait a few minutes for the change to propagate, then try again.", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "cannot_connect": "Failed to connect.", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py index daae98d21953..2ac6eaf3cde7 100644 --- a/tests/components/google_health/test_config_flow.py +++ b/tests/components/google_health/test_config_flow.py @@ -2,7 +2,10 @@ from unittest.mock import AsyncMock, patch -from google_health_api.exceptions import GoogleHealthApiError +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, +) from google_health_api.model import Identity import pytest @@ -175,6 +178,51 @@ async def test_config_flow_get_identity_error( assert result["reason"] == "cannot_connect" +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if the Google Health API is not enabled.""" + mock_google_health_client.get_identity.side_effect = HealthApiForbiddenException + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_not_enabled" + assert result["description_placeholders"] == { + "url": "https://console.developers.google.com/apis/api/health.googleapis.com/overview" + } + + @pytest.mark.usefixtures( "current_request_with_host", "mock_setup_entry", "setup_credentials" ) From 5f29fa6e7264873cd6f7fc86dca7c9f590cec801 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:41:01 +0200 Subject: [PATCH 269/707] Use LockEntityStateAttribute enum in Yale (#175990) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/yale/lock.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index fbdbd69285de..8ef2f179b5cf 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -8,7 +8,11 @@ from yalexs.activity import ActivityType from yalexs.lock import Lock, LockOperation, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity -from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature +from homeassistant.components.lock import ( + LockEntity, + LockEntityFeature, + LockEntityStateAttribute, +) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -140,5 +144,7 @@ class YaleLock(YaleEntity, RestoreEntity, LockEntity): if not (last_state := await self.async_get_last_state()): return - if ATTR_CHANGED_BY in last_state.attributes: - self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY] + if LockEntityStateAttribute.CHANGED_BY in last_state.attributes: + self._attr_changed_by = last_state.attributes[ + LockEntityStateAttribute.CHANGED_BY + ] From d33997d634c80f881cd086bb8675aad2e0afa9fe Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 9 Jul 2026 00:43:40 +1000 Subject: [PATCH 270/707] Fix Teslemetry streaming update progress tracking and state restore (#175749) --- homeassistant/components/teslemetry/update.py | 13 +- .../teslemetry/snapshots/test_update.ambr | 81 ++++++- tests/components/teslemetry/test_update.py | 217 +++++++++++++++++- 3 files changed, 300 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 7297e6cfc108..d3e552883d7e 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -135,6 +135,7 @@ class TeslemetryStreamingUpdateEntity( _download_percentage: int = 0 _install_percentage: int = 0 + _scheduled: bool = False def __init__( self, @@ -154,7 +155,7 @@ class TeslemetryStreamingUpdateEntity( await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: self._attr_in_progress = state.attributes.get("in_progress", False) - self._install_percentage = state.attributes.get("install_percentage", False) + self._attr_update_percentage = state.attributes.get("update_percentage") self._attr_installed_version = state.attributes.get("installed_version") self._attr_latest_version = state.attributes.get("latest_version") self._attr_supported_features = UpdateEntityFeature( @@ -162,6 +163,7 @@ class TeslemetryStreamingUpdateEntity( "supported_features", self._attr_supported_features ) ) + self._scheduled = self._attr_in_progress self.async_write_ha_state() self.async_on_remove( @@ -217,7 +219,8 @@ class TeslemetryStreamingUpdateEntity( ) -> None: """Handle software update scheduled start time.""" - self._attr_in_progress = value is not None + self._scheduled = value is not None + self._async_update_progress() self.async_write_ha_state() def _async_handle_software_update_version(self, value: str | None) -> None: @@ -238,12 +241,12 @@ class TeslemetryStreamingUpdateEntity( def _async_update_progress(self) -> None: """Update the progress of the update.""" - if 1 < self._download_percentage < 100: + if 0 < self._download_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._download_percentage - elif self._install_percentage > 10: + elif 10 < self._install_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._install_percentage else: - self._attr_in_progress = False + self._attr_in_progress = self._scheduled self._attr_update_percentage = None diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 096deb8341f6..8b0dc76c820b 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -130,7 +130,7 @@ : 0, : '/api/brands/integration/teslemetry/icon.png', : 'Test Update', - : False, + : True, : '2025.1.1', : '2025.2.1', : None, @@ -138,6 +138,31 @@ : None, : , : None, + : 50, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[install_complete] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : False, + : '2025.1.1', + : '2025.2.1', + : None, + : None, + : None, + : , + : None, : None, }), 'context': , @@ -155,7 +180,7 @@ : 0, : '/api/brands/integration/teslemetry/icon.png', : 'Test Update', - : False, + : True, : '2025.1.1', : '2025.2.1', : None, @@ -163,7 +188,7 @@ : None, : , : None, - : None, + : 50, }), 'context': , 'entity_id': 'update.test_update', @@ -248,3 +273,53 @@ 'state': 'off', }) # --- +# name: test_update_streaming_scheduled_not_clobbered[downloading_after_schedule_cleared] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : True, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : 5, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_streaming_scheduled_not_clobbered[scheduled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : True, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index af6c9d847f16..6c3d48f61cdb 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -11,14 +11,14 @@ from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.update import INSTALLING from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache async def test_update( @@ -113,6 +113,8 @@ async def test_update_streaming( await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 50 assert state == snapshot(name="downloading") mock_add_listener.send( @@ -130,6 +132,9 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + # Install percentages up to 10% reflect Tesla's pre-installation step, not real progress + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="ready") mock_add_listener.send( @@ -147,8 +152,30 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 50 assert state == snapshot(name="installing") + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + # 100% installed is complete, not in progress + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + assert state == snapshot(name="install_complete") + mock_add_listener.send( { "vin": VEHICLE_DATA_ALT["response"]["vin"], @@ -164,9 +191,193 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="updated") await reload_platform(hass, entry, [Platform.UPDATE]) state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="restored") + + +async def test_update_streaming_scheduled_not_clobbered( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Test that a scheduled install stays in progress until real progress or cancellation.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.UPDATE]) + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: 1735689600, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] is None + assert state == snapshot(name="scheduled") + + # Download begins and the schedule clears in the same payload: progress must win + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 5, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 5 + assert state == snapshot(name="downloading_after_schedule_cleared") + + +async def test_update_streaming_restore( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, +) -> None: + """Test that the streaming update entity restores update_percentage, not install_percentage.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entity_id = "update.test_update" + mock_restore_cache( + hass, + ( + State( + entity_id, + STATE_ON, + attributes={ + "in_progress": True, + "update_percentage": 42, + "installed_version": "2025.1.1", + "latest_version": "2025.2.1", + }, + ), + ), + ) + + await setup_platform(hass, [Platform.UPDATE]) + + state = hass.states.get(entity_id) + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 42 + + +@pytest.mark.parametrize( + ("data", "expected_in_progress", "expected_percentage"), + [ + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 0, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="download_0pct_is_idle", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + True, + 1, + id="download_1pct_is_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="download_100pct_is_complete", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_1pct_is_not_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 10, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_10pct_is_not_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 11, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + True, + 11, + id="install_11pct_is_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_100pct_is_complete", + ), + ], +) +async def test_update_streaming_progress_thresholds( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + data: dict[Signal, int | None], + expected_in_progress: bool, + expected_percentage: int | None, +) -> None: + """Test download/install progress threshold edge cases.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.UPDATE]) + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": data, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is expected_in_progress + assert state.attributes["update_percentage"] == expected_percentage From be8acc37b782dec5573e1ae0d70b69cee2f1849b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:53:25 +0200 Subject: [PATCH 271/707] Use EntityStateAttribute enum in Tomorrow.io (#175974) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/tomorrowio/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index d03dc77a62f1..7067858ad264 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -21,11 +21,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import ( CONF_API_KEY, - CONF_FRIENDLY_NAME, CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -141,7 +141,9 @@ class TomorrowioConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_NAME] = DEFAULT_NAME # Append zone name if it exists and we are using the default name if zone_state := async_active_zone(self.hass, latitude, longitude): - zone_name = zone_state.attributes[CONF_FRIENDLY_NAME] + zone_name = zone_state.attributes[ + EntityStateAttribute.FRIENDLY_NAME + ] user_input[CONF_NAME] += f" - {zone_name}" try: await TomorrowioV4( From 3cd3082eba32cb2a5ee5d348596b02cc3e67d674 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 8 Jul 2026 16:54:03 +0200 Subject: [PATCH 272/707] Refactor bsblan schedule service helpers (#172340) --- homeassistant/components/bsblan/services.py | 145 +++++++------------- 1 file changed, 50 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 315320fd5b83..f48ea2552213 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -2,7 +2,7 @@ from datetime import time import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Final from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot import voluptuous as vol @@ -29,6 +29,16 @@ ATTR_FRIDAY_SLOTS = "friday_slots" ATTR_SATURDAY_SLOTS = "saturday_slots" ATTR_SUNDAY_SLOTS = "sunday_slots" +_DAY_NAME_SLOT_ATTR_PAIRS: tuple[tuple[str, str], ...] = ( + ("monday", ATTR_MONDAY_SLOTS), + ("tuesday", ATTR_TUESDAY_SLOTS), + ("wednesday", ATTR_WEDNESDAY_SLOTS), + ("thursday", ATTR_THURSDAY_SLOTS), + ("friday", ATTR_FRIDAY_SLOTS), + ("saturday", ATTR_SATURDAY_SLOTS), + ("sunday", ATTR_SUNDAY_SLOTS), +) + # Schema for a single time slot _SLOT_SCHEMA = vol.Schema( @@ -39,16 +49,16 @@ _SLOT_SCHEMA = vol.Schema( ) +_WEEKLY_SCHEDULE_FIELDS: Final[dict[vol.Marker, Any]] = { + vol.Optional(slot_attr): vol.All(cv.ensure_list, [_SLOT_SCHEMA]) + for _, slot_attr in _DAY_NAME_SLOT_ATTR_PAIRS +} + + SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + **_WEEKLY_SCHEDULE_FIELDS, } ) @@ -98,11 +108,26 @@ def _convert_time_slots_to_day_schedule( return DaySchedule(slots=time_slots) -async def set_hot_water_schedule(service_call: ServiceCall) -> None: - """Set hot water heating schedule.""" - device_id = service_call.data[ATTR_DEVICE_ID] +def _build_weekly_schedule_days( + service_call: ServiceCall, +) -> dict[str, DaySchedule | None]: + """Build day-name -> schedule values from the service call data. + + Days omitted from the service call map to None, which tells python-bsblan not to + modify that day. + """ + return { + day_name: _convert_time_slots_to_day_schedule(service_call.data.get(attr_name)) + for day_name, attr_name in _DAY_NAME_SLOT_ATTR_PAIRS + } + + +def _resolve_config_entry( + service_call: ServiceCall, +) -> tuple[BSBLanConfigEntry, dr.DeviceEntry]: + """Resolve device_id from a service call into a loaded BSBLAN config entry.""" + device_id: str = service_call.data[ATTR_DEVICE_ID] - # Get the device and config entry device_registry = dr.async_get(service_call.hass) device_entry = device_registry.async_get(device_id) @@ -137,56 +162,20 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: translation_placeholders={"device_name": device_entry.name or device_id}, ) + return entry, device_entry + + +async def set_hot_water_schedule(service_call: ServiceCall) -> None: + """Set hot water heating schedule.""" + entry, _ = _resolve_config_entry(service_call) client = entry.runtime_data.client - # Convert time slots to DaySchedule objects - monday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_MONDAY_SLOTS) - ) - tuesday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_TUESDAY_SLOTS) - ) - wednesday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_WEDNESDAY_SLOTS) - ) - thursday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_THURSDAY_SLOTS) - ) - friday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_FRIDAY_SLOTS) - ) - saturday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_SATURDAY_SLOTS) - ) - sunday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_SUNDAY_SLOTS) - ) + days = _build_weekly_schedule_days(service_call) + dhw_schedule = DHWSchedule(**days) - # Create the DHWSchedule object - dhw_schedule = DHWSchedule( - monday=monday, - tuesday=tuesday, - wednesday=wednesday, - thursday=thursday, - friday=friday, - saturday=saturday, - sunday=sunday, - ) - - LOGGER.debug( - "Setting hot water schedule - Monday: %s, Tuesday: %s, Wednesday: %s, " - "Thursday: %s, Friday: %s, Saturday: %s, Sunday: %s", - monday, - tuesday, - wednesday, - thursday, - friday, - saturday, - sunday, - ) + LOGGER.debug("Setting hot water schedule: %s", dhw_schedule) try: - # Call the BSB-LAN API to set the schedule await client.set_hot_water_schedule(dhw_schedule) except BSBLANError as err: raise HomeAssistantError( @@ -201,45 +190,11 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: async def async_sync_time(service_call: ServiceCall) -> None: """Synchronize BSB-LAN device time with Home Assistant.""" - device_id: str = service_call.data[ATTR_DEVICE_ID] - - # Get the device and config entry - device_registry = dr.async_get(service_call.hass) - device_entry = device_registry.async_get(device_id) - - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_device_id", - translation_placeholders={"device_id": device_id}, - ) - - # Find the config entry for this device - matching_entries: list[BSBLanConfigEntry] = [ - entry - for entry in service_call.hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ] - - if not matching_entries: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_config_entry_for_device", - translation_placeholders={"device_id": device_entry.name or device_id}, - ) - - entry = matching_entries[0] - - # Verify the config entry is loaded - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_loaded", - translation_placeholders={"device_name": device_entry.name or device_id}, - ) - + entry, device_entry = _resolve_config_entry(service_call) client = entry.runtime_data.client - await async_sync_device_time(client, device_entry.name or device_id) + await async_sync_device_time( + client, device_entry.name or service_call.data[ATTR_DEVICE_ID] + ) SYNC_TIME_SCHEMA = vol.Schema( From a58d55e7a5b304c98e2977910fca994e5c14cd23 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 17:19:34 +0200 Subject: [PATCH 273/707] MELCloud Home add energy consumption (#173948) --- .../components/melcloud_home/coordinator.py | 40 +++++++++++- .../components/melcloud_home/sensor.py | 65 +++++++++++++++---- .../components/melcloud_home/strings.json | 3 + tests/components/melcloud_home/conftest.py | 14 +++- .../melcloud_home/fixtures/energy.json | 5 ++ .../melcloud_home/snapshots/test_sensor.ambr | 62 ++++++++++++++++++ 6 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 tests/components/melcloud_home/fixtures/energy.json diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py index 1f9a745ac30f..f3d4f8ddb4cd 100644 --- a/homeassistant/components/melcloud_home/coordinator.py +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import utcnow from .const import DOMAIN @@ -50,6 +51,8 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): self.client = client self.ata_units: dict[str, ATAUnit] = {} self.atw_units: dict[str, ATWUnit] = {} + self.ata_energy: dict[str, float | None] = {} + self.atw_energy: dict[str, float | None] = {} self.known_ata: set[str] = set() self.known_atw: set[str] = set() self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = [] @@ -60,7 +63,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): current_ata = [ unit for building in data.buildings for unit in building.air_to_air_units ] - self.ata_units = {unit.id: unit for unit in current_ata} + current_ata_ids = {unit.id for unit in current_ata} self.known_ata &= current_ata_ids new_ata_ids = current_ata_ids - self.known_ata @@ -74,7 +77,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): current_atw_units = [ unit for building in data.buildings for unit in building.air_to_water_units ] - self.atw_units = {unit.id: unit for unit in current_atw_units} + current_atw_ids = {unit.id for unit in current_atw_units} self.known_atw &= current_atw_ids new_atw_ids = current_atw_ids - self.known_atw @@ -108,6 +111,39 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): """Fetch data from the MELCloud Home API.""" try: data = await self.client.get_context() + + start_of_month = utcnow().replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + for building in data.buildings: + for ata_unit in building.air_to_air_units: + self.ata_units[ata_unit.id] = ata_unit + if ( + ata_unit.capabilities + and ata_unit.capabilities.has_energy_consumed_meter + ): + energy = await self.client.get_energy_telemetry( + ata_unit.id, + from_dt=start_of_month, + to_dt=utcnow(), + ) + self.ata_energy[ata_unit.id] = sum( + float(e.value) for e in energy + ) + for atw_unit in building.air_to_water_units: + self.atw_units[atw_unit.id] = atw_unit + if ( + atw_unit.capabilities + and atw_unit.capabilities.has_energy_consumed_meter + ): + energy = await self.client.get_energy_telemetry( + atw_unit.id, + from_dt=start_of_month, + to_dt=utcnow(), + ) + self.atw_energy[atw_unit.id] = sum( + float(e.value) for e in energy + ) except MelCloudHomeAuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 907c7033b56f..32ab89322397 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import override from aiomelcloudhome import ATAUnit, ATWUnit @@ -16,10 +17,12 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfEnergy, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -31,15 +34,16 @@ PARALLEL_UPDATES = 0 class ATASensorEntityDescription(SensorEntityDescription): """Class to hold MELCloud Home ATA sensor description.""" - value_fn: Callable[[ATAUnit], StateType] + value_fn: Callable[[ATAUnit, MelCloudHomeCoordinator], StateType] + exists_fn: Callable[[ATAUnit], bool] = lambda _: True @dataclass(frozen=True, kw_only=True) class ATWSensorEntityDescription(SensorEntityDescription): """Class to hold MELCloud Home ATW sensor description.""" - value_fn: Callable[[ATWUnit], StateType] - exists_fn: Callable[[ATWUnit], bool] = lambda unit: True + value_fn: Callable[[ATWUnit, MelCloudHomeCoordinator], StateType] + exists_fn: Callable[[ATWUnit], bool] = lambda _: True ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = ( @@ -50,7 +54,7 @@ ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature, + value_fn=lambda unit, _: unit.room_temperature, ), ATASensorEntityDescription( key="rssi", @@ -59,7 +63,19 @@ ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda unit: unit.rssi, + value_fn=lambda unit, _: unit.rssi, + ), + ATASensorEntityDescription( + key="energy_consumed", + translation_key="energy_consumed", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda unit, coordinator: coordinator.ata_energy.get(unit.id), + exists_fn=lambda unit: bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ), ), ) @@ -71,7 +87,7 @@ ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature_zone1, + value_fn=lambda unit, _: unit.room_temperature_zone1, ), ATWSensorEntityDescription( key="room_temperature_zone_2", @@ -80,7 +96,7 @@ ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature_zone2, + value_fn=lambda unit, _: unit.room_temperature_zone2, exists_fn=lambda unit: bool( (unit.capabilities and unit.capabilities.has_zone2) or (unit.capabilities is None and unit.has_zone2) @@ -93,7 +109,7 @@ ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.tank_water_temperature, + value_fn=lambda unit, _: unit.tank_water_temperature, ), ATWSensorEntityDescription( key="rssi", @@ -102,7 +118,19 @@ ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda unit: unit.rssi, + value_fn=lambda unit, _: unit.rssi, + ), + ATWSensorEntityDescription( + key="energy_consumed", + translation_key="energy_consumed", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda unit, coordinator: coordinator.atw_energy.get(unit.id), + exists_fn=lambda unit: bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ), ), ) @@ -120,6 +148,7 @@ async def async_setup_entry( ATASensor(coordinator, entity_description, unit) for entity_description in ATA_SENSORS for unit in units + if entity_description.exists_fn(unit) ) def _async_add_new_atw_units(units: list[ATWUnit]) -> None: @@ -157,7 +186,14 @@ class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity): @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.unit) + return self.entity_description.value_fn(self.unit, self.coordinator) + + @property + def last_reset(self) -> datetime | None: + """Return start of month for TOTAL energy sensors.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return None class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity): @@ -180,4 +216,11 @@ class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity): @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.unit) + return self.entity_description.value_fn(self.unit, self.coordinator) + + @property + def last_reset(self) -> datetime | None: + """Return start of month for TOTAL energy sensors.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return None diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json index b5eadcc104cf..a56154c757ff 100644 --- a/homeassistant/components/melcloud_home/strings.json +++ b/homeassistant/components/melcloud_home/strings.json @@ -97,6 +97,9 @@ } }, "sensor": { + "energy_consumed": { + "name": "Energy consumed (monthly)" + }, "room_temperature": { "name": "Room temperature" }, diff --git a/tests/components/melcloud_home/conftest.py b/tests/components/melcloud_home/conftest.py index 274319f87ad1..1eb04056809e 100644 --- a/tests/components/melcloud_home/conftest.py +++ b/tests/components/melcloud_home/conftest.py @@ -4,12 +4,17 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch from aiomelcloudhome import MELCloudHome, UserContext +from aiomelcloudhome.models.telemetry import TelemetryValue import pytest from homeassistant.components.melcloud_home.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from tests.common import MockConfigEntry, load_json_value_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) MOCK_USER_INPUT = { CONF_EMAIL: "user@example.com", @@ -36,8 +41,13 @@ def mock_melcloud_client() -> Generator[AsyncMock]: """Mock MELCloud Home client.""" client = AsyncMock(MELCloudHome) client.get_context.return_value = UserContext.model_validate( - load_json_value_fixture("context.json", DOMAIN) + load_json_object_fixture("context.json", DOMAIN) ) + client.get_energy_telemetry.return_value = [ + TelemetryValue.model_validate(value) + for value in load_json_array_fixture("energy.json", DOMAIN) + ] + with ( patch( "homeassistant.components.melcloud_home.MELCloudHome", diff --git a/tests/components/melcloud_home/fixtures/energy.json b/tests/components/melcloud_home/fixtures/energy.json new file mode 100644 index 000000000000..b0099eaed4bd --- /dev/null +++ b/tests/components/melcloud_home/fixtures/energy.json @@ -0,0 +1,5 @@ +[ + { "time": "2026-01-14 02:00:00.000000000", "value": "100.0" }, + { "time": "2026-01-14 03:00:00.000000000", "value": "150.5" }, + { "time": "2026-01-14 04:00:00.000000000", "value": "200.0" } +] diff --git a/tests/components/melcloud_home/snapshots/test_sensor.ambr b/tests/components/melcloud_home/snapshots/test_sensor.ambr index 85a3c4d31e27..44243d8f274d 100644 --- a/tests/components/melcloud_home/snapshots/test_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_sensor.ambr @@ -1,4 +1,66 @@ # serializer version: 1 +# name: test_all_entities[sensor.heat_pump_energy_consumed_monthly-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_energy_consumed_monthly', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed (monthly)', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed (monthly)', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'atw-unit-uuid-1_energy_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.heat_pump_energy_consumed_monthly-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Pump Energy consumed (monthly)', + 'last_reset': '2026-06-01T00:00:00+00:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy_consumed_monthly', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4505', + }) +# --- # name: test_all_entities[sensor.heat_pump_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 642b07948219f8ff37cb9b5632c7795ef1c5386b Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 8 Jul 2026 11:22:41 -0400 Subject: [PATCH 274/707] Refactor SupervisorJobs into a DataUpdateCoordinator (#174237) --- homeassistant/components/hassio/__init__.py | 11 +- homeassistant/components/hassio/const.py | 5 + .../components/hassio/coordinator.py | 215 +++++++++++++++++- homeassistant/components/hassio/jobs.py | 179 --------------- homeassistant/components/hassio/update.py | 16 +- tests/components/conftest.py | 4 - tests/components/hassio/test_jobs.py | 113 +++++++-- 7 files changed, 320 insertions(+), 223 deletions(-) delete mode 100644 homeassistant/components/hassio/jobs.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index b38caf53c336..3dc73beb3e83 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -60,6 +60,7 @@ from .const import ( DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + JOBS_COORDINATOR, MAIN_COORDINATOR, STATS_COORDINATOR, ) @@ -67,6 +68,7 @@ from .coordinator import ( HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + SupervisorJobsCoordinator, get_addons_info, get_addons_list, get_addons_stats, @@ -326,9 +328,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[MAIN_COORDINATOR] = coordinator - addon_coordinator = HassioAddOnDataUpdateCoordinator( - hass, entry, dev_reg, coordinator.jobs - ) + jobs_coordinator = SupervisorJobsCoordinator(hass, entry) + await jobs_coordinator.async_config_entry_first_refresh() + hass.data[JOBS_COORDINATOR] = jobs_coordinator + + addon_coordinator = HassioAddOnDataUpdateCoordinator(hass, entry, dev_reg) await addon_coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = addon_coordinator @@ -437,5 +441,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(MAIN_COORDINATOR, None) hass.data.pop(ADDONS_COORDINATOR, None) hass.data.pop(STATS_COORDINATOR, None) + hass.data.pop(JOBS_COORDINATOR, None) return unload_ok diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 418f956c46c2..8f68e0903238 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + SupervisorJobsCoordinator, ) from .handler import HassIO from .issues import SupervisorIssues @@ -103,6 +104,9 @@ ADDONS_COORDINATOR: HassKey[HassioAddOnDataUpdateCoordinator] = HassKey( STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey( "hassio_stats_coordinator" ) +JOBS_COORDINATOR: HassKey[SupervisorJobsCoordinator] = HassKey( + "hassio_jobs_coordinator" +) DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) @@ -126,6 +130,7 @@ DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list") HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5) HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15) HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60) +SUPERVISOR_JOBS_UPDATE_INTERVAL = timedelta(minutes=15) ATTR_AUTO_UPDATE = "auto_update" ATTR_VERSION = "version" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index b08cd67939eb..aeadbb419fe2 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -2,10 +2,11 @@ import asyncio from collections import defaultdict -from collections.abc import Awaitable -from dataclasses import dataclass +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace import logging from typing import TYPE_CHECKING, Any, cast, override +from uuid import UUID from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import ( @@ -17,6 +18,7 @@ from aiohasupervisor.models import ( HostInfo, InstalledAddon, InstalledAddonComplete, + Job, NetworkInfo, NFSMountResponse, OSInfo, @@ -29,7 +31,12 @@ from aiohasupervisor.models import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MANUFACTURER -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + callback, + is_callback_check_partial, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -59,6 +66,7 @@ from .const import ( DATA_SUPERVISOR_INFO, DATA_SUPERVISOR_STATS, DOMAIN, + EVENT_JOB, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, HASSIO_ADDON_UPDATE_INTERVAL, @@ -67,12 +75,12 @@ from .const import ( REQUEST_REFRESH_DELAY, STARTUP_COMPLETE, SUPERVISOR_CONTAINER, + SUPERVISOR_JOBS_UPDATE_INTERVAL, UPDATE_KEY_SUPERVISOR, SupervisorEntityModel, ) from .exceptions import HassioNotReadyError from .handler import get_supervisor_client -from .jobs import SupervisorJobs if TYPE_CHECKING: from .issues import SupervisorIssues @@ -80,6 +88,200 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +@dataclass(slots=True, frozen=True) +class JobSubscription: + """Subscribe for updates on jobs which match filters. + + UUID is preferred match but only available in cases of a background API that + returns the UUID before taking the action. Others are used to match jobs only + if UUID is omitted. Either name or UUID is required to be able to match. + + event_callback must be safe annotated as a homeassistant.core.callback + and safe to call in the event loop. + """ + + event_callback: Callable[[Job], None] + uuid: str | None = None + name: str | None = None + reference: str | None = None + + def __post_init__(self) -> None: + """Validate at least one filter option is present.""" + if not self.name and not self.uuid: + raise ValueError("Either name or uuid must be provided!") + if not is_callback_check_partial(self.event_callback): + raise ValueError("event_callback must be a homeassistant.core.callback!") + + def matches(self, job: Job) -> bool: + """Return true if job matches subscription filters.""" + if self.uuid: + return job.uuid == self.uuid + return job.name == self.name and self.reference in (None, job.reference) + + +class SupervisorJobsCoordinator(DataUpdateCoordinator[dict[UUID, Job]]): + """Manage access to Supervisor jobs.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize object.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="SupervisorJobsCoordinator", + update_interval=SUPERVISOR_JOBS_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # hammering the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self._supervisor_client = get_supervisor_client(hass) + self._subscriptions: set[JobSubscription] = set() + self._dispatcher_disconnect: Callable[[], None] | None = None + self._noop_listener_disconnect: Callable[[], None] | None = None + + @property + def current_jobs(self) -> list[Job]: + """Return current jobs.""" + return list(self.data.values()) if self.data is not None else [] + + @staticmethod + def _build_jobs(jobs: list[Job]) -> dict[UUID, Job]: + """Flatten jobs and child jobs into a UUID keyed cache.""" + job_queue: list[Job] = jobs.copy() + cached_jobs: dict[UUID, Job] = {} + + while job_queue: + job = job_queue.pop(0) + job_queue.extend(job.child_jobs) + cached_jobs[job.uuid] = replace(job, child_jobs=[]) + + return cached_jobs + + @override + async def _async_update_data(self) -> dict[UUID, Job]: + """Fetch data from Supervisor.""" + job_data = await self._supervisor_client.jobs.info() + return self._build_jobs(job_data.jobs) + + def _process_job_change(self, job: Job) -> None: + """Process a job change by triggering callbacks on subscribers.""" + for sub in self._subscriptions: + if sub.matches(job): + sub.event_callback(job) + + def _process_job_deltas( + self, + previous_jobs: dict[UUID, Job], + current_jobs: dict[UUID, Job], + ) -> None: + """Notify subscribers about changes between two job caches.""" + for job in current_jobs.values(): + if (previous_job := previous_jobs.get(job.uuid)) is not None and ( + previous_job == job + ): + continue + self._process_job_change(job) + + for uuid, job in previous_jobs.items(): + if uuid not in current_jobs and job.done is False: + self._process_job_change(replace(job, done=True)) + + def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: + """Subscribe to updates for job. Return callback is used to unsubscribe. + + If any jobs match the subscription at the time this is called, runs the + callback on them. + """ + self._subscriptions.add(subscription) + + # Connect a stub listener to start the update interval polling on first subscriber + if self._noop_listener_disconnect is None: + self._noop_listener_disconnect = self.async_add_listener(lambda: None) + + # Run the callback on each existing match + # We catch all errors to prevent an error in one from stopping the others + for match in [job for job in self.current_jobs if subscription.matches(job)]: + try: + subscription.event_callback(match) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error encountered processing Supervisor Job (%s %s %s) - %s", + match.name, + match.reference, + match.uuid, + err, + ) + + def _unsubscribe() -> None: + self._subscriptions.discard(subscription) + + # Stop polling if there are no more subscribers + if not self._subscriptions and self._noop_listener_disconnect is not None: + self._noop_listener_disconnect() + self._noop_listener_disconnect = None + + return _unsubscribe + + @callback + @override + def _async_refresh_finished(self) -> None: + """Register to receive Supervisor events after the first successful refresh.""" + if self.last_update_success and self._dispatcher_disconnect is None: + self._dispatcher_disconnect = async_dispatcher_connect( + self.hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs + ) + + @override + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data and notify subscribers about cache changes.""" + previous_jobs = self.data or {} + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) + if self.last_update_success and self.data is not None: + self._process_job_deltas(previous_jobs, self.data) + + @override + async def async_shutdown(self) -> None: + """Shut down the coordinator.""" + await super().async_shutdown() + if self._dispatcher_disconnect: + self._dispatcher_disconnect() + self._dispatcher_disconnect = None + + @callback + def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None: + """Update job data cache from supervisor events.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE + ): + self.config_entry.async_create_task(self.hass, self.async_request_refresh()) + + elif event[ATTR_WS_EVENT] == EVENT_JOB: + job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []}) + previous_jobs = self.data or {} + updated_jobs = {**previous_jobs, job.uuid: job} + if job.done: + updated_jobs.pop(job.uuid, None) + self.async_set_updated_data(updated_jobs) + self._process_job_change(job) + + @dataclass class HassioMainData: """Data class for HassioMainDataUpdateCoordinator.""" @@ -591,7 +793,6 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry, - jobs: SupervisorJobs, ) -> None: """Initialize coordinator.""" super().__init__( @@ -610,7 +811,6 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): self.dev_reg = dev_reg self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set) self.supervisor_client = get_supervisor_client(hass) - self.jobs = jobs @override async def _async_update_data(self) -> HassioAddonData: @@ -800,7 +1000,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): self.dev_reg = dev_reg self.is_hass_os = False self.supervisor_client = get_supervisor_client(hass) - self.jobs = SupervisorJobs(hass) self._dispatcher_disconnect = async_dispatcher_connect( hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event ) @@ -854,7 +1053,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): ), ) mounts_info = await client.mounts.info() - await self.jobs.refresh_data(is_first_update) except SupervisorError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err @@ -951,4 +1149,3 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): """Shut down and clean up when config entry unloaded.""" await super().async_shutdown() self._dispatcher_disconnect() - self.jobs.unload() diff --git a/homeassistant/components/hassio/jobs.py b/homeassistant/components/hassio/jobs.py deleted file mode 100644 index a7445b33b5e9..000000000000 --- a/homeassistant/components/hassio/jobs.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Track Supervisor job data and allow subscription to updates.""" - -from collections.abc import Callable -from dataclasses import dataclass, replace -from functools import partial -import logging -from typing import Any -from uuid import UUID - -from aiohasupervisor.models import Job - -from homeassistant.core import ( - CALLBACK_TYPE, - HomeAssistant, - callback, - is_callback_check_partial, -) -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from .const import ( - ATTR_DATA, - ATTR_STARTUP, - ATTR_UPDATE_KEY, - ATTR_WS_EVENT, - EVENT_JOB, - EVENT_SUPERVISOR_EVENT, - EVENT_SUPERVISOR_UPDATE, - STARTUP_COMPLETE, - UPDATE_KEY_SUPERVISOR, -) -from .handler import get_supervisor_client - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(slots=True, frozen=True) -class JobSubscription: - """Subscribe for updates on jobs which match filters. - - UUID is preferred match but only available in cases of a background API that - returns the UUID before taking the action. Others are used to match jobs only - if UUID is omitted. Either name or UUID is required to be able to match. - - event_callback must be safe annotated as a homeassistant.core.callback - and safe to call in the event loop. - """ - - event_callback: Callable[[Job], Any] - uuid: str | None = None - name: str | None = None - reference: str | None = None - - def __post_init__(self) -> None: - """Validate at least one filter option is present.""" - if not self.name and not self.uuid: - raise ValueError("Either name or uuid must be provided!") - if not is_callback_check_partial(self.event_callback): - raise ValueError("event_callback must be a homeassistant.core.callback!") - - def matches(self, job: Job) -> bool: - """Return true if job matches subscription filters.""" - if self.uuid: - return job.uuid == self.uuid - return job.name == self.name and self.reference in (None, job.reference) - - -class SupervisorJobs: - """Manage access to Supervisor jobs.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize object.""" - self._hass = hass - self._supervisor_client = get_supervisor_client(hass) - self._jobs: dict[UUID, Job] = {} - self._subscriptions: set[JobSubscription] = set() - self._dispatcher_disconnect: Callable[[], None] | None = None - - @property - def current_jobs(self) -> list[Job]: - """Return current jobs.""" - return list(self._jobs.values()) - - def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: - """Subscribe to updates for job. Return callback is used to unsubscribe. - - If any jobs match the subscription at the time this is called, runs the - callback on them. - """ - self._subscriptions.add(subscription) - - # Run the callback on each existing match - # We catch all errors to prevent an error in one from stopping the others - for match in [job for job in self._jobs.values() if subscription.matches(job)]: - try: - subscription.event_callback(match) - except Exception as err: # noqa: BLE001 - _LOGGER.error( - "Error encountered processing Supervisor Job (%s %s %s) - %s", - match.name, - match.reference, - match.uuid, - err, - ) - - return partial(self._subscriptions.discard, subscription) - - async def refresh_data(self, first_update: bool = False) -> None: - """Refresh job data.""" - job_data = await self._supervisor_client.jobs.info() - job_queue: list[Job] = job_data.jobs.copy() - new_jobs: dict[UUID, Job] = {} - changed_jobs: list[Job] = [] - - # Rebuild our job cache from new info and compare to find changes - while job_queue: - job = job_queue.pop(0) - job_queue.extend(job.child_jobs) - job = replace(job, child_jobs=[]) - - if job.uuid not in self._jobs or job != self._jobs[job.uuid]: - changed_jobs.append(job) - new_jobs[job.uuid] = replace(job, child_jobs=[]) - - # For any jobs that disappeared which weren't done, tell subscribers they - # changed to done. We don't know what else happened to them so leave the - # rest of their state as is rather then guessing - changed_jobs.extend( - [ - replace(job, done=True) - for uuid, job in self._jobs.items() - if uuid not in new_jobs and job.done is False - ] - ) - - # Replace our cache and inform subscribers of all changes - self._jobs = new_jobs - for job in changed_jobs: - self._process_job_change(job) - - # If this is the first update register to receive Supervisor events - if first_update: - self._dispatcher_disconnect = async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs - ) - - @callback - def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None: - """Update job data cache from supervisor events.""" - if ATTR_WS_EVENT not in event: - return - - if ( - event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE - and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR - and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE - ): - self._hass.async_create_task(self.refresh_data()) - - elif event[ATTR_WS_EVENT] == EVENT_JOB: - job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []}) - self._jobs[job.uuid] = job - self._process_job_change(job) - - def _process_job_change(self, job: Job) -> None: - """Process a job change by triggering callbacks on subscribers.""" - for sub in self._subscriptions: - if sub.matches(job): - sub.event_callback(job) - - # If the job is done, pop it from our cache if present after processing is done - if job.done and job.uuid in self._jobs: - del self._jobs[job.uuid] - - @callback - def unload(self) -> None: - """Unregister with dispatcher on config entry unload.""" - if self._dispatcher_disconnect: - self._dispatcher_disconnect() - self._dispatcher_disconnect = None diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 10b0c10362fc..ff5d25ba7df3 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -17,15 +17,19 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR -from .coordinator import AddonData +from .const import ( + ADDONS_COORDINATOR, + ATTR_VERSION_LATEST, + JOBS_COORDINATOR, + MAIN_COORDINATOR, +) +from .coordinator import AddonData, JobSubscription from .entity import ( HassioAddonEntity, HassioCoreEntity, HassioOSEntity, HassioSupervisorEntity, ) -from .jobs import JobSubscription from .update_helper import update_addon, update_core, update_os ENTITY_DESCRIPTION = UpdateEntityDescription( @@ -220,7 +224,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription( self._update_job_changed, name="addon_manager_update", @@ -398,7 +402,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription(self._update_job_changed, name="supervisor_update") ) ) @@ -468,7 +472,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription( self._update_job_changed, name="home_assistant_core_update" ) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1ccb6de16684..31facbab450a 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -899,10 +899,6 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.issues.get_supervisor_client", return_value=supervisor_client, ), - patch( - "homeassistant.components.hassio.jobs.get_supervisor_client", - return_value=supervisor_client, - ), patch( "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, diff --git a/tests/components/hassio/test_jobs.py b/tests/components/hassio/test_jobs.py index fca6691020d0..11bbd227da5e 100644 --- a/tests/components/hassio/test_jobs.py +++ b/tests/components/hassio/test_jobs.py @@ -9,14 +9,25 @@ from uuid import uuid4 from aiohasupervisor.models import Job, JobsInfo import pytest -from homeassistant.components.hassio.const import DOMAIN, MAIN_COORDINATOR -from homeassistant.components.hassio.coordinator import HassioMainDataUpdateCoordinator -from homeassistant.components.hassio.jobs import JobSubscription +from homeassistant.components.hassio.const import ( + DOMAIN, + JOBS_COORDINATOR, + MAIN_COORDINATOR, + REQUEST_REFRESH_DELAY, + SUPERVISOR_JOBS_UPDATE_INTERVAL, +) +from homeassistant.components.hassio.coordinator import ( + HassioMainDataUpdateCoordinator, + JobSubscription, + SupervisorJobsCoordinator, +) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .test_init import MOCK_ENVIRON +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -65,10 +76,10 @@ async def test_job_manager_setup(hass: HomeAssistant, jobs_info: AsyncMock) -> N assert result jobs_info.assert_called_once() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert len(data_coordinator.jobs.current_jobs) == 2 - assert data_coordinator.jobs.current_jobs[0].name == "test_job" - assert data_coordinator.jobs.current_jobs[1].name == "test_inner_job" + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 2 + assert jobs_coordinator.current_jobs[0].name == "test_job" + assert jobs_coordinator.current_jobs[1].name == "test_inner_job" @pytest.mark.usefixtures("all_setup_requests") @@ -100,8 +111,8 @@ async def test_job_manager_ws_updates( jobs_info.reset_mock() client = await hass_supervisor_ws_client() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert not data_coordinator.jobs.current_jobs + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert not jobs_coordinator.current_jobs # Make an example listener job_data: Job | None = None @@ -114,7 +125,7 @@ async def test_job_manager_ws_updates( subscription = JobSubscription( mock_subscription_callback, name="test_job", reference="test" ) - unsubscribe = data_coordinator.jobs.subscribe(subscription) + unsubscribe = jobs_coordinator.subscribe(subscription) # Send start of job update await client.send_json( @@ -146,7 +157,7 @@ async def test_job_manager_ws_updates( assert job_data.progress == 0 assert job_data.done is False # One job in the cache - assert len(data_coordinator.jobs.current_jobs) == 1 + assert len(jobs_coordinator.current_jobs) == 1 # Example progress update await client.send_json( @@ -178,7 +189,7 @@ async def test_job_manager_ws_updates( assert job_data.progress == 50 assert job_data.done is False # Same job, same number of jobs in cache - assert len(data_coordinator.jobs.current_jobs) == 1 + assert len(jobs_coordinator.current_jobs) == 1 # Unrelated job update - name change, subscriber should not receive await client.send_json( @@ -208,7 +219,7 @@ async def test_job_manager_ws_updates( assert job_data.name == "test_job" assert job_data.reference == "test" # New job, cache increases - assert len(data_coordinator.jobs.current_jobs) == 2 + assert len(jobs_coordinator.current_jobs) == 2 # Unrelated job update - reference change, subscriber should not receive await client.send_json( @@ -238,7 +249,7 @@ async def test_job_manager_ws_updates( assert job_data.name == "test_job" assert job_data.reference == "test" # New job, cache increases - assert len(data_coordinator.jobs.current_jobs) == 3 + assert len(jobs_coordinator.current_jobs) == 3 # Unsubscribe mock listener, should not receive final update unsubscribe() @@ -271,7 +282,7 @@ async def test_job_manager_ws_updates( assert job_data.progress == 50 assert job_data.done is False # Job ended, cache decreases - assert len(data_coordinator.jobs.current_jobs) == 2 + assert len(jobs_coordinator.current_jobs) == 2 # REST API should not be used during this sequence jobs_info.assert_not_called() @@ -306,9 +317,9 @@ async def test_job_manager_reload_on_supervisor_restart( assert result jobs_info.assert_called_once() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert len(data_coordinator.jobs.current_jobs) == 1 - assert data_coordinator.jobs.current_jobs[0].name == "test_job" + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 1 + assert jobs_coordinator.current_jobs[0].name == "test_job" jobs_info.reset_mock() jobs_info.return_value = JobsInfo(ignore_conditions=[], jobs=[]) @@ -323,7 +334,7 @@ async def test_job_manager_reload_on_supervisor_restart( job_data = job subscription = JobSubscription(mock_subscription_callback, name="test_job") - data_coordinator.jobs.subscribe(subscription) + jobs_coordinator.subscribe(subscription) # Send supervisor restart signal await client.send_json( @@ -341,12 +352,18 @@ async def test_job_manager_reload_on_supervisor_restart( assert msg["success"] await hass.async_block_till_done() + # Advance time past the debouncer cooldown for the refresh to complete + async_fire_time_changed( + hass, dt_util.utcnow() + dt_util.dt.timedelta(seconds=REQUEST_REFRESH_DELAY + 1) + ) + await hass.async_block_till_done() + # Listener should be told job is done and cache cleared out jobs_info.assert_called_once() assert job_data.name == "test_job" assert job_data.reference == "test" assert job_data.done is True - assert not data_coordinator.jobs.current_jobs + assert not jobs_coordinator.current_jobs @pytest.mark.usefixtures("all_setup_requests") @@ -378,7 +395,7 @@ async def test_subscribe_returns_unsubscribe_when_job_already_matches( assert result client = await hass_supervisor_ws_client() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] received: list[Job] = [] @@ -387,7 +404,7 @@ async def test_subscribe_returns_unsubscribe_when_job_already_matches( received.append(job) subscription = JobSubscription(mock_subscription_callback, name="test_job") - unsubscribe = data_coordinator.jobs.subscribe(subscription) + unsubscribe = jobs_coordinator.subscribe(subscription) # Existing matching job is delivered immediately, and a callable unsubscribe # is returned (not the None result of the callback) @@ -422,3 +439,55 @@ async def test_subscribe_returns_unsubscribe_when_job_already_matches( await hass.async_block_till_done() assert len(received) == 1 + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_job_manager_periodic_refresh( + hass: HomeAssistant, jobs_info: AsyncMock +) -> None: + """Test job manager performs periodic refresh as backstop for dropped WS events.""" + jobs_info.return_value = JobsInfo( + ignore_conditions=[], + jobs=[ + Job( + name="test_job", + reference="test", + uuid=uuid4(), + progress=0, + stage=None, + done=False, + errors=[], + created=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + extra=None, + child_jobs=[], + ) + ], + ) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + jobs_info.assert_called_once() + + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 1 + + # Subscribe to job updates + job_data: Job | None = None + + @callback + def mock_subscription_callback(job: Job) -> None: + nonlocal job_data + job_data = job + + subscription = JobSubscription(mock_subscription_callback, name="test_job") + jobs_coordinator.subscribe(subscription) + + # Reset mock to verify periodic refresh + jobs_info.reset_mock() + + # Advance time past the SUPERVISOR_JOBS_UPDATE_INTERVAL to trigger periodic refresh + async_fire_time_changed(hass, dt_util.utcnow() + SUPERVISOR_JOBS_UPDATE_INTERVAL) + await hass.async_block_till_done() + + # Periodic refresh should have called jobs_info + jobs_info.assert_called() From bd575bbbce918c0cd8475b2294cb2159259fd36a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:23:10 +0200 Subject: [PATCH 275/707] Use UpdateEntityStateAttribute enum in Shelly (#175995) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/shelly/update.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index c75565c1dd76..304a90d19ae7 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -10,12 +10,11 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( - ATTR_INSTALLED_VERSION, - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -391,7 +390,9 @@ class RpcSleepingUpdateEntity( if self.last_state is None: return None - return self.last_state.attributes.get(ATTR_INSTALLED_VERSION) + return self.last_state.attributes.get( + UpdateEntityStateAttribute.INSTALLED_VERSION + ) @property @override @@ -407,7 +408,7 @@ class RpcSleepingUpdateEntity( if self.last_state is None: return None - return self.last_state.attributes.get(ATTR_LATEST_VERSION) + return self.last_state.attributes.get(UpdateEntityStateAttribute.LATEST_VERSION) @property @override From 37143f048e5d17939924498bb747b9dc096232e1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:28:18 +0200 Subject: [PATCH 276/707] Use TimerEntityStateAttribute enum in timer (#175971) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/timer/__init__.py | 14 ++++++++---- .../components/timer/reproduce_state.py | 9 +++++--- homeassistant/components/timer/trigger.py | 22 +++++++++++++------ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 90a72ec4119f..de19c3cf1e71 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -292,20 +292,26 @@ class Timer(collection.CollectionEntity, RestoreEntity): # Begin restoring state self._state = state.state - self._last_transition = state.attributes.get(ATTR_LAST_TRANSITION) + self._last_transition = state.attributes.get( + TimerEntityStateAttribute.LAST_TRANSITION + ) # Nothing more to do if the timer is idle if self._state == STATUS_IDLE: return - self._running_duration = cv.time_period(state.attributes[ATTR_DURATION]) + self._running_duration = cv.time_period( + state.attributes[TimerEntityStateAttribute.DURATION] + ) # If the timer was paused, we restore the remaining time if self._state == STATUS_PAUSED: - self._remaining = cv.time_period(state.attributes[ATTR_REMAINING]) + self._remaining = cv.time_period( + state.attributes[TimerEntityStateAttribute.REMAINING] + ) return # If we get here, the timer must have been active so we need to decide what # to do based on end time and the current time - end = cv.datetime(state.attributes[ATTR_FINISHES_AT]) + end = cv.datetime(state.attributes[TimerEntityStateAttribute.FINISHES_AT]) # If there is time remaining in the timer, restore the remaining time then # start the timer if (remaining := end - dt_util.utcnow().replace(microsecond=0)) > timedelta(0): diff --git a/homeassistant/components/timer/reproduce_state.py b/homeassistant/components/timer/reproduce_state.py index 95cec586c3d9..10bef2a774d1 100644 --- a/homeassistant/components/timer/reproduce_state.py +++ b/homeassistant/components/timer/reproduce_state.py @@ -17,6 +17,7 @@ from . import ( STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED, + TimerEntityStateAttribute, ) _LOGGER = logging.getLogger(__name__) @@ -45,15 +46,17 @@ async def _async_reproduce_state( # Return if we are already at the right state. if cur_state.state == state.state and cur_state.attributes.get( ATTR_DURATION - ) == state.attributes.get(ATTR_DURATION): + ) == state.attributes.get(TimerEntityStateAttribute.DURATION): return service_data = {ATTR_ENTITY_ID: state.entity_id} if state.state == STATUS_ACTIVE: service = SERVICE_START - if ATTR_DURATION in state.attributes: - service_data[ATTR_DURATION] = state.attributes[ATTR_DURATION] + if TimerEntityStateAttribute.DURATION in state.attributes: + service_data[ATTR_DURATION] = state.attributes[ + TimerEntityStateAttribute.DURATION + ] elif state.state == STATUS_PAUSED: service = SERVICE_PAUSE elif state.state == STATUS_IDLE: diff --git a/homeassistant/components/timer/trigger.py b/homeassistant/components/timer/trigger.py index 8f4152ce507f..28349cff3b2a 100644 --- a/homeassistant/components/timer/trigger.py +++ b/homeassistant/components/timer/trigger.py @@ -26,7 +26,8 @@ from homeassistant.helpers.trigger import ( from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE +from . import DOMAIN, STATUS_ACTIVE +from .const import TimerEntityStateAttribute CONF_REMAINING = "remaining" @@ -86,7 +87,9 @@ class TimeRemainingTrigger(Trigger): if to_state.state != STATUS_ACTIVE: return - finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT) + finishes_at_str = to_state.attributes.get( + TimerEntityStateAttribute.FINISHES_AT + ) if finishes_at_str is None: return @@ -166,19 +169,24 @@ class TimeRemainingTrigger(Trigger): TRIGGERS: dict[str, type[Trigger]] = { "cancelled": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "cancelled" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "cancelled", ), "finished": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "finished" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "finished", ), "paused": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "paused" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "paused", ), "restarted": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "restarted" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "restarted", ), "started": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "started", ), "remaining_time_reached": TimeRemainingTrigger, } From 5c283358b6b8e30f21c407c838503ee4c1e87b17 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:28:52 +0200 Subject: [PATCH 277/707] Use ZoneEntityStateAttribute enum in Anthropic (#175975) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/anthropic/config_flow.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 48705f909e58..1cc1aabb3013 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -9,7 +9,7 @@ import anthropic import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components.zone import ENTITY_ID_HOME +from homeassistant.components.zone import ENTITY_ID_HOME, ZoneEntityStateAttribute from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntryState, @@ -18,14 +18,7 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_API_KEY, - CONF_LLM_HASS_API, - CONF_NAME, - CONF_PROMPT, -) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.selector import ( @@ -569,8 +562,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): { "role": "user", "content": "Where are the following coordinates located: " - f"({zone_home.attributes[ATTR_LATITUDE]}," - f" {zone_home.attributes[ATTR_LONGITUDE]})?", + f"({zone_home.attributes[ZoneEntityStateAttribute.LATITUDE]}," + f" {zone_home.attributes[ZoneEntityStateAttribute.LONGITUDE]})?", } ], max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]), From 76737bdc078366cd2d9fd6bce0b6debc0f4b5ffe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:35:54 +0200 Subject: [PATCH 278/707] Use UpdateEntityStateAttribute enum in Z-Wave JS (#175991) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/zwave_js/update.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 91b3559f4466..7dd68ff7e3c1 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -19,11 +19,11 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node.firmware import NodeFirmwareUpdateInfo from homeassistant.components.update import ( - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import EntityCategory from homeassistant.core import CoreState, HomeAssistant, callback @@ -355,7 +355,11 @@ class ZWaveFirmwareUpdateEntity(ZWaveNodeBaseEntity, UpdateEntity): # If we have a complete previous state, use that to set the latest version if ( (state := await self.async_get_last_state()) - and (latest_version := state.attributes.get(ATTR_LATEST_VERSION)) + and ( + latest_version := state.attributes.get( + UpdateEntityStateAttribute.LATEST_VERSION + ) + ) is not None and (extra_data := await self.async_get_last_extra_data()) and ( From 68b45cc8065b93b954264e5dbb8dd58b45b88977 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 17:42:40 +0200 Subject: [PATCH 279/707] Move StarLine services to async_setup (#175472) --- homeassistant/components/starline/__init__.py | 65 ++++--------------- homeassistant/components/starline/services.py | 65 +++++++++++++++++++ 2 files changed, 77 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/starline/services.py diff --git a/homeassistant/components/starline/__init__.py b/homeassistant/components/starline/__init__.py index 5ae3994632f0..d41bec14781a 100644 --- a/homeassistant/components/starline/__init__.py +++ b/homeassistant/components/starline/__init__.py @@ -1,12 +1,11 @@ """The StarLine component.""" -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .account import StarlineAccount from .const import ( @@ -15,13 +14,19 @@ from .const import ( DEFAULT_SCAN_OBD_INTERVAL, DOMAIN, PLATFORMS, - SERVICE_SET_SCAN_INTERVAL, - SERVICE_SET_SCAN_OBD_INTERVAL, - SERVICE_UPDATE_STATE, ) +from .services import async_setup_services type StarlineConfigEntry = ConfigEntry[StarlineAccount] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the StarLine integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) -> bool: """Set up the StarLine device from a config entry.""" @@ -41,52 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def async_set_scan_interval(call: ServiceCall) -> None: - """Set scan interval.""" - options = dict(entry.options) - options[CONF_SCAN_INTERVAL] = call.data[CONF_SCAN_INTERVAL] - hass.config_entries.async_update_entry(entry=entry, options=options) - - async def async_set_scan_obd_interval(call: ServiceCall) -> None: - """Set OBD info scan interval.""" - options = dict(entry.options) - options[CONF_SCAN_OBD_INTERVAL] = call.data[CONF_SCAN_INTERVAL] - hass.config_entries.async_update_entry(entry=entry, options=options) - - async def async_update(call: ServiceCall | None = None) -> None: - """Update all data.""" - await account.update() - await account.update_obd() - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, async_update) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_SCAN_INTERVAL, - async_set_scan_interval, - schema=vol.Schema( - { - vol.Required(CONF_SCAN_INTERVAL): vol.All( - vol.Coerce(int), vol.Range(min=10) - ) - } - ), - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_SCAN_OBD_INTERVAL, - async_set_scan_obd_interval, - schema=vol.Schema( - { - vol.Required(CONF_SCAN_INTERVAL): vol.All( - vol.Coerce(int), vol.Range(min=180) - ) - } - ), - ) - entry.async_on_unload(entry.add_update_listener(async_options_updated)) await async_options_updated(hass, entry) diff --git a/homeassistant/components/starline/services.py b/homeassistant/components/starline/services.py new file mode 100644 index 000000000000..2ae893b27927 --- /dev/null +++ b/homeassistant/components/starline/services.py @@ -0,0 +1,65 @@ +"""Services for the StarLine integration.""" + +import voluptuous as vol + +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant, ServiceCall, callback + +from .const import ( + CONF_SCAN_OBD_INTERVAL, + DOMAIN, + SERVICE_SET_SCAN_INTERVAL, + SERVICE_SET_SCAN_OBD_INTERVAL, + SERVICE_UPDATE_STATE, +) + +SET_SCAN_INTERVAL_SCHEMA = vol.Schema( + {vol.Required(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=10))} +) + +SET_SCAN_OBD_INTERVAL_SCHEMA = vol.Schema( + {vol.Required(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=180))} +) + + +async def _async_update(call: ServiceCall) -> None: + """Update all data.""" + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + account = entry.runtime_data + await account.update() + await account.update_obd() + + +async def _async_set_scan_interval(call: ServiceCall) -> None: + """Set scan interval.""" + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + options = dict(entry.options) + options[CONF_SCAN_INTERVAL] = call.data[CONF_SCAN_INTERVAL] + call.hass.config_entries.async_update_entry(entry=entry, options=options) + + +async def _async_set_scan_obd_interval(call: ServiceCall) -> None: + """Set OBD info scan interval.""" + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + options = dict(entry.options) + options[CONF_SCAN_OBD_INTERVAL] = call.data[CONF_SCAN_INTERVAL] + call.hass.config_entries.async_update_entry(entry=entry, options=options) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register StarLine services.""" + + hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, _async_update) + hass.services.async_register( + DOMAIN, + SERVICE_SET_SCAN_INTERVAL, + _async_set_scan_interval, + schema=SET_SCAN_INTERVAL_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_SCAN_OBD_INTERVAL, + _async_set_scan_obd_interval, + schema=SET_SCAN_OBD_INTERVAL_SCHEMA, + ) From c1e5a7ee3e8c65258c48eec306affe0cc0aae173 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:58:28 +0200 Subject: [PATCH 280/707] Use EntityStateAttribute enum in LG webOS Smart TV (#175988) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/webostv/media_player.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 5907865aa091..fdced86518b8 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, ServiceResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -155,7 +155,8 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ): self._supported_features = ( state.attributes.get( - ATTR_SUPPORTED_FEATURES, MediaPlayerEntityFeature(0) + EntityStateAttribute.SUPPORTED_FEATURES, + MediaPlayerEntityFeature(0), ) & ~MediaPlayerEntityFeature.TURN_ON ) From 4a68642b8261d9ef1809821562ebadd7f1e21ead Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:00:35 +0200 Subject: [PATCH 281/707] Add override decorator to last_reset in melcloud_home (#176009) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/melcloud_home/sensor.py | 2 ++ .../melcloud_home/snapshots/test_sensor.ambr | 12 ++++++------ tests/components/melcloud_home/test_sensor.py | 1 + 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 32ab89322397..5f4f5e62c182 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -189,6 +189,7 @@ class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity): return self.entity_description.value_fn(self.unit, self.coordinator) @property + @override def last_reset(self) -> datetime | None: """Return start of month for TOTAL energy sensors.""" if self.entity_description.state_class == SensorStateClass.TOTAL: @@ -219,6 +220,7 @@ class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity): return self.entity_description.value_fn(self.unit, self.coordinator) @property + @override def last_reset(self) -> datetime | None: """Return start of month for TOTAL energy sensors.""" if self.entity_description.state_class == SensorStateClass.TOTAL: diff --git a/tests/components/melcloud_home/snapshots/test_sensor.ambr b/tests/components/melcloud_home/snapshots/test_sensor.ambr index 44243d8f274d..c2952d0ec309 100644 --- a/tests/components/melcloud_home/snapshots/test_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_sensor.ambr @@ -6,7 +6,7 @@ ]), 'area_id': None, 'capabilities': dict({ - 'state_class': , + : , }), 'config_entry_id': , 'config_subentry_id': , @@ -47,11 +47,11 @@ # name: test_all_entities[sensor.heat_pump_energy_consumed_monthly-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Heat Pump Energy consumed (monthly)', - 'last_reset': '2026-06-01T00:00:00+00:00', - 'state_class': , - 'unit_of_measurement': , + : 'energy', + : 'Heat Pump Energy consumed (monthly)', + : '2026-06-01T00:00:00+00:00', + : , + : , }), 'context': , 'entity_id': 'sensor.heat_pump_energy_consumed_monthly', diff --git a/tests/components/melcloud_home/test_sensor.py b/tests/components/melcloud_home/test_sensor.py index 7afed42c9ec0..3b3e1372f944 100644 --- a/tests/components/melcloud_home/test_sensor.py +++ b/tests/components/melcloud_home/test_sensor.py @@ -20,6 +20,7 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None: @pytest.mark.usefixtures("mock_melcloud_client") +@pytest.mark.freeze_time("2026-06-08 12:00:00+00:00") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, From 18cd083f2c9ee3274c5564220c81918488123540 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 8 Jul 2026 18:24:53 +0200 Subject: [PATCH 282/707] Upnp rollover sensors (#168573) Co-authored-by: J. Nick Koston Co-authored-by: Erwin Douna Co-authored-by: Joost Lekkerkerker --- .../components/dlna_dmr/manifest.json | 2 +- .../components/dlna_dms/manifest.json | 2 +- .../components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/const.py | 4 + homeassistant/components/upnp/device.py | 8 ++ homeassistant/components/upnp/icons.json | 6 ++ homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/upnp/sensor.py | 46 +++++++++++ homeassistant/components/upnp/strings.json | 12 +++ .../components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- tests/components/upnp/conftest.py | 4 + tests/components/upnp/test_binary_sensor.py | 4 + tests/components/upnp/test_sensor.py | 82 +++++++++++++++++++ 16 files changed, 174 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index dc218ac32a48..d1290658b04c 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -9,7 +9,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.47.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index f92582336f47..09e75ff3b0ca 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.46.2"], + "requirements": ["async-upnp-client==0.47.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index d1a3a01ded8c..6753818ccd05 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==3.0.5", "wakeonlan==3.3.0", - "async-upnp-client==0.46.2" + "async-upnp-client==0.47.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 99dce6f63424..c8a23a2cca3c 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.46.2"] + "requirements": ["async-upnp-client==0.47.0"] } diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index d85675d8a4dc..2b9604c42dd0 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -15,8 +15,12 @@ PACKETS_RECEIVED = "packets_received" PACKETS_SENT = "packets_sent" KIBIBYTES_PER_SEC_RECEIVED = "kibibytes_per_sec_received" KIBIBYTES_PER_SEC_SENT = "kibibytes_per_sec_sent" +KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER = "kibibytes_per_sec_received_no_rollover" +KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER = "kibibytes_per_sec_sent_no_rollover" PACKETS_PER_SEC_RECEIVED = "packets_per_sec_received" PACKETS_PER_SEC_SENT = "packets_per_sec_sent" +PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER = "packets_per_sec_received_no_rollover" +PACKETS_PER_SEC_SENT_NO_ROLLOVER = "packets_per_sec_sent_no_rollover" TIMESTAMP = "timestamp" DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{UnitOfTime.SECONDS}" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index e3d9690afb89..a93a7a61f981 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -22,10 +22,14 @@ from .const import ( BYTES_RECEIVED, BYTES_SENT, KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER, KIBIBYTES_PER_SEC_SENT, + KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER, LOGGER as _LOGGER, PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER, PACKETS_PER_SEC_SENT, + PACKETS_PER_SEC_SENT_NO_ROLLOVER, PACKETS_RECEIVED, PACKETS_SENT, PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4, @@ -256,8 +260,12 @@ class Device: ROUTER_IP: get_value(igd_state.external_ip_address), KIBIBYTES_PER_SEC_RECEIVED: igd_state.kibibytes_per_sec_received, KIBIBYTES_PER_SEC_SENT: igd_state.kibibytes_per_sec_sent, + KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER: igd_state.kibibytes_per_sec_received_no_rollover, + KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER: igd_state.kibibytes_per_sec_sent_no_rollover, PACKETS_PER_SEC_RECEIVED: igd_state.packets_per_sec_received, PACKETS_PER_SEC_SENT: igd_state.packets_per_sec_sent, + PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER: igd_state.packets_per_sec_received_no_rollover, + PACKETS_PER_SEC_SENT_NO_ROLLOVER: igd_state.packets_per_sec_sent_no_rollover, PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4: get_value( igd_state.port_mapping_number_of_entries ), diff --git a/homeassistant/components/upnp/icons.json b/homeassistant/components/upnp/icons.json index 8f6b1c493662..ad6abd63ccfa 100644 --- a/homeassistant/components/upnp/icons.json +++ b/homeassistant/components/upnp/icons.json @@ -7,9 +7,15 @@ "packet_download_speed": { "default": "mdi:transmission-tower" }, + "packet_download_speed_no_rollover_handling": { + "default": "mdi:transmission-tower" + }, "packet_upload_speed": { "default": "mdi:transmission-tower" }, + "packet_upload_speed_no_rollover_handling": { + "default": "mdi:transmission-tower" + }, "packets_received": { "default": "mdi:database" }, diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index f1d6a3a18fd5..251a2a9f2c32 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.47.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index be3c25455bd3..379e4b033c6c 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -25,10 +25,14 @@ from .const import ( DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER, KIBIBYTES_PER_SEC_SENT, + KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER, LOGGER, PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER, PACKETS_PER_SEC_SENT, + PACKETS_PER_SEC_SENT_NO_ROLLOVER, PACKETS_RECEIVED, PACKETS_SENT, PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4, @@ -126,6 +130,28 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), + UpnpSensorEntityDescription( + key=BYTES_RECEIVED, + translation_key="download_speed_no_rollover_handling", + value_key=KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER, + unique_id="KiB/sec_received_no_rollover", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + UpnpSensorEntityDescription( + key=BYTES_SENT, + translation_key="upload_speed_no_rollover_handling", + value_key=KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER, + unique_id="KiB/sec_sent_no_rollover", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, translation_key="packet_download_speed", @@ -146,6 +172,26 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), + UpnpSensorEntityDescription( + key=PACKETS_RECEIVED, + translation_key="packet_download_speed_no_rollover_handling", + value_key=PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER, + unique_id="packets/sec_received_no_rollover", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + UpnpSensorEntityDescription( + key=PACKETS_SENT, + translation_key="packet_upload_speed_no_rollover_handling", + value_key=PACKETS_PER_SEC_SENT_NO_ROLLOVER, + unique_id="packets/sec_sent_no_rollover", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), ) diff --git a/homeassistant/components/upnp/strings.json b/homeassistant/components/upnp/strings.json index a62c8c4af080..79464d3bc57d 100644 --- a/homeassistant/components/upnp/strings.json +++ b/homeassistant/components/upnp/strings.json @@ -33,15 +33,24 @@ "download_speed": { "name": "Download speed" }, + "download_speed_no_rollover_handling": { + "name": "Download speed (no rollover handling)" + }, "external_ip": { "name": "External IP" }, "packet_download_speed": { "name": "Packet download speed" }, + "packet_download_speed_no_rollover_handling": { + "name": "Packet download speed (no rollover handling)" + }, "packet_upload_speed": { "name": "Packet upload speed" }, + "packet_upload_speed_no_rollover_handling": { + "name": "Packet upload speed (no rollover handling)" + }, "packets_received": { "name": "Packets received" }, @@ -54,6 +63,9 @@ "upload_speed": { "name": "Upload speed" }, + "upload_speed_no_rollover_handling": { + "name": "Upload speed (no rollover handling)" + }, "uptime": { "name": "Uptime" }, diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 26c776975cd8..26bc44411bc3 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.2"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.47.0"], "zeroconf": [ { "name": "yeelink-*", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7f2f12be77ae..27b3ae60b2bf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.46.2 +async-upnp-client==0.47.0 atomicwrites-homeassistant==1.4.1 attrs==26.1.0 audioop-lts==0.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 9ebe7825b0b9..128292c89023 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -566,7 +566,7 @@ asusrouter==1.21.3 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.46.2 +async-upnp-client==0.47.0 # homeassistant.components.arve asyncarve==0.1.1 diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 5a7e06411d3b..c9f2fdc11911 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -106,6 +106,10 @@ def mock_igd_device(mock_async_create_device) -> IgdDevice: kibibytes_per_sec_sent=None, packets_per_sec_received=None, packets_per_sec_sent=None, + kibibytes_per_sec_received_no_rollover=None, + kibibytes_per_sec_sent_no_rollover=None, + packets_per_sec_received_no_rollover=None, + packets_per_sec_sent_no_rollover=None, port_mapping_number_of_entries=0, ) diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 058534102e63..537d8c2753c0 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -35,6 +35,10 @@ async def test_upnp_binary_sensors( kibibytes_per_sec_sent=None, packets_per_sec_received=None, packets_per_sec_sent=None, + kibibytes_per_sec_received_no_rollover=None, + kibibytes_per_sec_sent_no_rollover=None, + packets_per_sec_received_no_rollover=None, + packets_per_sec_sent_no_rollover=None, port_mapping_number_of_entries=0, ) diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 177892c24c85..8c8a8ee24355 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -28,6 +29,26 @@ async def test_upnp_sensors( assert hass.states.get("sensor.mock_name_upload_speed").state == "unknown" assert hass.states.get("sensor.mock_name_packet_download_speed").state == "unknown" assert hass.states.get("sensor.mock_name_packet_upload_speed").state == "unknown" + assert ( + hass.states.get("sensor.mock_name_download_speed_no_rollover_handling").state + == "unknown" + ) + assert ( + hass.states.get("sensor.mock_name_upload_speed_no_rollover_handling").state + == "unknown" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_download_speed_no_rollover_handling" + ).state + == "unknown" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_upload_speed_no_rollover_handling" + ).state + == "unknown" + ) # Second poll. mock_igd_device: IgdDevice = mock_config_entry.igd_device @@ -45,6 +66,10 @@ async def test_upnp_sensors( kibibytes_per_sec_sent=20.0, packets_per_sec_received=30.0, packets_per_sec_sent=40.0, + kibibytes_per_sec_received_no_rollover=10.0, + kibibytes_per_sec_sent_no_rollover=20.0, + packets_per_sec_received_no_rollover=30.0, + packets_per_sec_sent_no_rollover=40.0, port_mapping_number_of_entries=0, ) @@ -62,3 +87,60 @@ async def test_upnp_sensors( assert hass.states.get("sensor.mock_name_upload_speed").state == "20.0" assert hass.states.get("sensor.mock_name_packet_download_speed").state == "30.0" assert hass.states.get("sensor.mock_name_packet_upload_speed").state == "40.0" + assert ( + hass.states.get("sensor.mock_name_download_speed_no_rollover_handling").state + == "10.0" + ) + assert ( + hass.states.get("sensor.mock_name_upload_speed_no_rollover_handling").state + == "20.0" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_download_speed_no_rollover_handling" + ).state + == "30.0" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_upload_speed_no_rollover_handling" + ).state + == "40.0" + ) + + +async def test_upnp_sensors_no_rollover_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test no-rollover sensors are disabled by default and can be enabled.""" + disabled_entity_id = "sensor.mock_name_download_speed_no_rollover_handling" + other_disabled_entity_ids = ( + "sensor.mock_name_upload_speed_no_rollover_handling", + "sensor.mock_name_packet_download_speed_no_rollover_handling", + "sensor.mock_name_packet_upload_speed_no_rollover_handling", + ) + + assert hass.states.get(disabled_entity_id) is None + for entity_id in other_disabled_entity_ids: + assert hass.states.get(entity_id) is None + + entry = entity_registry.async_get(disabled_entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + for entity_id in other_disabled_entity_ids: + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(disabled_entity_id, disabled_by=None) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + enabled_state = hass.states.get(disabled_entity_id) + assert enabled_state is not None + assert enabled_state.state == "unknown" From a9b830bea299280c86403aab990e3f55eb0195ed Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 19:35:22 +0200 Subject: [PATCH 283/707] Bump modbus-connection to 3.4.1 (#176014) Co-authored-by: Claude --- homeassistant/components/modbus_connection/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json index 79cfe652c1ce..a3f132e4e6d9 100644 --- a/homeassistant/components/modbus_connection/manifest.json +++ b/homeassistant/components/modbus_connection/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["modbus_connection", "tmodbus"], "quality_scale": "bronze", - "requirements": ["modbus-connection[tmodbus]==3.3.0"] + "requirements": ["modbus-connection[tmodbus]==3.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 128292c89023..5650ef32fc51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1589,7 +1589,7 @@ mitsubishi-comfort==0.3.2 moat-ble==0.1.1 # homeassistant.components.modbus_connection -modbus-connection[tmodbus]==3.3.0 +modbus-connection[tmodbus]==3.4.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 From 6e1994dd7f21a32c30f33aac8d23ec234635242e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:39:43 +0200 Subject: [PATCH 284/707] Use LightEntityStateAttribute enum in ZHA (#175993) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/zha/light.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 69ec6a03fa2d..43d4f7c421f3 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -12,7 +12,6 @@ from zha.application.platforms.light.const import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, @@ -21,6 +20,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + LightEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform @@ -215,20 +215,27 @@ class Light(LightEntity, ZHAEntity): """Restore entity state.""" color_temp = ( color_util.color_temperature_kelvin_to_mired(color_temp_k) - if (color_temp_k := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) + if ( + color_temp_k := state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ) + ) else None ) self.entity_data.entity.restore_external_state_attributes( state=(state.state == STATE_ON), off_with_transition=state.attributes.get(OFF_WITH_TRANSITION), off_brightness=state.attributes.get(OFF_BRIGHTNESS), - brightness=state.attributes.get(ATTR_BRIGHTNESS), + brightness=state.attributes.get(LightEntityStateAttribute.BRIGHTNESS), color_temp=color_temp, - xy_color=state.attributes.get(ATTR_XY_COLOR), + xy_color=state.attributes.get(LightEntityStateAttribute.XY_COLOR), color_mode=( - HA_TO_ZHA_COLOR_MODE[ColorMode(state.attributes[ATTR_COLOR_MODE])] - if state.attributes.get(ATTR_COLOR_MODE) is not None + HA_TO_ZHA_COLOR_MODE[ + ColorMode(state.attributes[LightEntityStateAttribute.COLOR_MODE]) + ] + if state.attributes.get(LightEntityStateAttribute.COLOR_MODE) + is not None else None ), - effect=state.attributes.get(ATTR_EFFECT), + effect=state.attributes.get(LightEntityStateAttribute.EFFECT), ) From 10d4db794eb33b6662df688c1537c3f18f59ea50 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:49:38 +0200 Subject: [PATCH 285/707] bump py-synologydsm-api to 2.10.3 (#176012) --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index fd3fb5a6f181..00d599145150 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.10.2"], + "requirements": ["py-synologydsm-api==2.10.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Basic:1", diff --git a/requirements_all.txt b/requirements_all.txt index 5650ef32fc51..62db5bb3012a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1955,7 +1955,7 @@ py-schluter==0.1.7 py-sucks==0.9.11 # homeassistant.components.synology_dsm -py-synologydsm-api==2.10.2 +py-synologydsm-api==2.10.3 # homeassistant.components.unifi_access py-unifi-access==1.3.0 From a744adad0d5516c10cd426be9143f3a37eee2568 Mon Sep 17 00:00:00 2001 From: rubempoli <66323535+rubempoli@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:15:20 -0300 Subject: [PATCH 286/707] Bump TP-Link Omada client to 1.5.9 (#175881) --- homeassistant/components/tplink_omada/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 27ad50855a7d..8aa8663670ab 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["tplink-omada-client==1.5.8"] + "requirements": ["tplink-omada-client==1.5.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 62db5bb3012a..ef39571dbd6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3207,7 +3207,7 @@ toonapi==0.3.0 total-connect-client==2026.7 # homeassistant.components.tplink_omada -tplink-omada-client==1.5.8 +tplink-omada-client==1.5.9 # homeassistant.components.transmission transmission-rpc==7.0.3 From 403b945b1b8d429220a79ab7c44516109f6e22d5 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 20:16:42 +0200 Subject: [PATCH 287/707] Add update platform to Portainer (#163685) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/portainer/__init__.py | 25 +- .../components/portainer/coordinator.py | 77 ++- .../components/portainer/strings.json | 5 + homeassistant/components/portainer/update.py | 187 +++++++ tests/components/portainer/conftest.py | 44 +- .../portainer/fixtures/container_inspect.json | 472 ++++++++++++++++++ .../fixtures/local_image_information.json | 90 ++++ .../portainer/snapshots/test_update.ambr | 373 ++++++++++++++ tests/components/portainer/test_init.py | 42 ++ tests/components/portainer/test_update.py | 140 ++++++ 10 files changed, 1448 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/portainer/update.py create mode 100644 tests/components/portainer/fixtures/container_inspect.json create mode 100644 tests/components/portainer/fixtures/local_image_information.json create mode 100644 tests/components/portainer/snapshots/test_update.ambr create mode 100644 tests/components/portainer/test_update.py diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index c4d453c83483..79a52f858efd 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -1,8 +1,9 @@ """The Portainer integration.""" +from datetime import timedelta import logging -from pyportainer import Portainer +from pyportainer import Portainer, PortainerImageWatcher from pyportainer.exceptions import PortainerError from homeassistant.config_entries import ConfigEntry @@ -12,9 +13,10 @@ from homeassistant.const import ( CONF_HOST, CONF_URL, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -32,6 +34,7 @@ _PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -55,8 +58,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> request_timeout=10, max_retries=API_MAX_RETRIES, ) + watcher = PortainerImageWatcher(client, interval=timedelta(hours=24)) coordinator = PortainerCoordinator(hass, entry, client) + coordinator.watcher = watcher await coordinator.async_config_entry_first_refresh() docker_system_df_client = Portainer( @@ -86,6 +91,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + @callback + def _start_watcher(_hass: HomeAssistant) -> None: + """Start the image watcher in the event loop.""" + watcher.start() + + @callback + def _stop_watcher(_event: Event) -> None: + """Stop the image watcher in the event loop.""" + watcher.stop() + + entry.async_on_unload(async_at_started(hass, _start_watcher)) + entry.async_on_unload(watcher.stop) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_watcher) + ) + return True diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index afa8af0a16b6..ba5357fb58a7 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging +import time from typing import override from pyportainer import ( @@ -22,10 +23,13 @@ from pyportainer.models.docker import ( DockerSystemDF, DockerVolume, DockerVolumeUsageData, + LocalImageInformation, + PortainerImageUpdateStatus, ) -from pyportainer.models.docker_inspect import DockerInfo, DockerVersion +from pyportainer.models.docker_inspect import DockerInfo, DockerInspect, DockerVersion from pyportainer.models.portainer import Endpoint from pyportainer.models.stacks import Stack +from pyportainer.watcher import PortainerImageWatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL @@ -63,9 +67,12 @@ class PortainerContainerData: """Container data held by the Portainer coordinator.""" container: DockerContainer + container_inspect: DockerInspect + local_image: LocalImageInformation + stack: Stack | None stats: DockerContainerStats | None stats_pre: DockerContainerStats | None - stack: Stack | None + image_status: PortainerImageUpdateStatus | None = None @dataclass(slots=True) @@ -185,8 +192,21 @@ class PortainerCoordinator( config_entry: PortainerConfigEntry docker_disk_space: PortainerDockerDiskSpaceCoordinator | None = None + watcher: PortainerImageWatcher | None = None _update_interval = DEFAULT_SCAN_INTERVAL + def __init__( + self, + hass: HomeAssistant, + config_entry: PortainerConfigEntry, + portainer: Portainer, + ) -> None: + """Initialize.""" + super().__init__(hass, config_entry, portainer) + self._image_cache: dict[ + tuple[int, str], tuple[float, DockerInspect, LocalImageInformation] + ] = {} + @override async def update_data(self) -> dict[int, PortainerCoordinatorData]: """Fetch data from Portainer API.""" @@ -271,6 +291,25 @@ class PortainerCoordinator( else None ) + ( + container_inspect, + local_image, + ) = await self._get_inspect_local_image(endpoint.id, container.id) + + image_status = ( + ( + result.status + if ( + result := self.watcher.results.get( + (endpoint.id, container.id) + ) + ) + else None + ) + if self.watcher + else None + ) + # Check if container belongs to a stack via docker compose label stack_name: str | None = ( container.labels.get("com.docker.compose.project") @@ -283,8 +322,11 @@ class PortainerCoordinator( container_map[container_name] = PortainerContainerData( container=container, + container_inspect=container_inspect, + local_image=local_image, stats=None, stats_pre=prev_container.stats if prev_container else None, + image_status=image_status, stack=stack_map[stack_name].stack if stack_name and stack_name in stack_map else None, @@ -432,6 +474,37 @@ class PortainerCoordinator( for stack_callback in self.new_stacks_callbacks: stack_callback(new_stack_data) + async def _get_inspect_local_image( + self, endpoint_id: int, container_id: str + ) -> tuple[DockerInspect, LocalImageInformation]: + """Fetch or retrieve cached container inspect and local image data.""" + if cached := self._image_cache.get((endpoint_id, container_id)): + cached_at, container_inspect, local_image = cached + if ( + self.watcher is None + or self.watcher.last_check is None + or cached_at >= self.watcher.last_check + ): + _LOGGER.debug( + "Using cached inspect and local image for endpoint %d, container %s", + endpoint_id, + container_id, + ) + return container_inspect, local_image + + container_inspect = await self.portainer.inspect_container( + endpoint_id, container_id + ) + local_image = await self.portainer.get_image( + endpoint_id, str(container_inspect.image) + ) + self._image_cache[(endpoint_id, container_id)] = ( + time.monotonic(), + container_inspect, + local_image, + ) + return container_inspect, local_image + class PortainerDockerDiskSpaceCoordinator( PortainerBaseCoordinator[dict[int, DockerSystemDF]] diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index d39cddc8c6c4..1a2854ac3e87 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -192,6 +192,11 @@ "stack": { "name": "Stack" } + }, + "update": { + "container_image_update": { + "name": "Image update available" + } } }, "exceptions": { diff --git a/homeassistant/components/portainer/update.py b/homeassistant/components/portainer/update.py new file mode 100644 index 000000000000..a40b43e8d61a --- /dev/null +++ b/homeassistant/components/portainer/update.py @@ -0,0 +1,187 @@ +"""Support for Portainer container updates.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, override + +from pyportainer import Portainer +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, +) +from pyportainer.models.docker import ( + DockerContainer, + LocalImageInformation, + PortainerImageUpdateStatus, +) + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + PortainerConfigEntry, + PortainerContainerData, + PortainerCoordinator, + PortainerCoordinatorData, +) +from .entity import PortainerContainerEntity + + +@dataclass(frozen=True, kw_only=True) +class PortainerContainerUpdateEntityDescription(UpdateEntityDescription): + """Describes Portainer container update entity.""" + + installed_version: Callable[[LocalImageInformation], str | None] + latest_version: Callable[[PortainerImageUpdateStatus | None], str | None] + update_func: Callable[ + [Portainer, int, str], + Awaitable[DockerContainer], + ] + + +PARALLEL_UPDATES = 1 +DEFAULT_RECREATE_TIMEOUT = timedelta(minutes=10) + + +CONTAINER_IMAGE: tuple[PortainerContainerUpdateEntityDescription] = ( + PortainerContainerUpdateEntityDescription( + key="container_image_update", + translation_key="container_image_update", + entity_category=EntityCategory.CONFIG, + installed_version=lambda data: ( + data.repo_digests[0].split("@")[1] + if data.repo_digests and isinstance(data.repo_digests[0], str) + else None + ), + latest_version=lambda data: data.registry_digest if data is not None else None, + update_func=( + lambda portainer, endpoint_id, container_id: portainer.container_recreate( + endpoint_id=endpoint_id, + container_id=container_id, + timeout=DEFAULT_RECREATE_TIMEOUT, + pull_image=True, + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer update entities based on a config entry.""" + coordinator = entry.runtime_data + + def _async_add_new_containers( + containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]], + ) -> None: + """Add new container update entities.""" + + async_add_entities( + PortainerContainerImageUpdateEntity( + coordinator, + entity_description, + container, + endpoint, + ) + for (endpoint, container) in containers + for entity_description in CONTAINER_IMAGE + ) + + coordinator.new_containers_callbacks.append(_async_add_new_containers) + _async_add_new_containers( + [ + (endpoint, container) + for endpoint in coordinator.data.values() + for container in endpoint.containers.values() + ] + ) + + +class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity): + """Representation of a Portainer container update.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + entity_description: PortainerContainerUpdateEntityDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerContainerUpdateEntityDescription, + device_info: PortainerContainerData, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer update entity.""" + self.entity_description = entity_description + super().__init__(coordinator, entity_description, device_info, via_device) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" + self._in_progress_old_version: str | None = None + + @override + @property + def title(self) -> str | None: + """Return title.""" + return self.device_name + + @override + @property + def installed_version(self) -> str | None: + """Return installed version.""" + return self.entity_description.installed_version( + self.container_data.local_image + ) + + @override + @property + def latest_version(self) -> str | None: + """Return latest version.""" + return self.entity_description.latest_version(self.container_data.image_status) + + @override + @property + def in_progress(self) -> bool: + """Return if an update is in progress.""" + return self._in_progress_old_version == self.installed_version + + @override + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install update.""" + self._in_progress_old_version = self.installed_version + try: + await self.entity_description.update_func( + self.coordinator.portainer, + self.endpoint_id, + self.container_data.container.id, + ) + except PortainerAuthenticationError as ex: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth_no_details", + ) from ex + except PortainerConnectionError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect_no_details", + ) from ex + else: + await self.coordinator.async_request_refresh() + finally: + self._in_progress_old_version = None diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index 54cd66d7ca99..1bd89ba728e0 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -1,17 +1,20 @@ """Common fixtures for the portainer tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from pyportainer.models.docker import ( DockerContainer, DockerContainerStats, DockerSystemDF, DockerVolume, + LocalImageInformation, + PortainerImageUpdateStatus, ) -from pyportainer.models.docker_inspect import DockerInfo, DockerVersion +from pyportainer.models.docker_inspect import DockerInfo, DockerInspect, DockerVersion from pyportainer.models.portainer import Endpoint, PortainerSystemStatus from pyportainer.models.stacks import Stack +from pyportainer.watcher import PortainerImageWatcherResult import pytest from homeassistant.components.portainer.const import DOMAIN @@ -45,7 +48,32 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_portainer_client() -> Generator[AsyncMock]: +def mock_portainer_watcher() -> Generator[MagicMock]: + """Mock PortainerImageWatcher with no results by default.""" + with patch( + "homeassistant.components.portainer.PortainerImageWatcher", autospec=True + ) as mock_watcher_class: + watcher = mock_watcher_class.return_value + watcher.last_check = None + watcher.results = { + ( + 1, + "aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + ): PortainerImageWatcherResult( + endpoint_id=1, + container_id="aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + status=PortainerImageUpdateStatus( + update_available=True, + local_digest="sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96", + registry_digest="sha256:newdigest123456789", + ), + ) + } + yield watcher + + +@pytest.fixture +def mock_portainer_client(mock_portainer_watcher: MagicMock) -> Generator[AsyncMock]: """Mock Portainer client with dynamic exception injection support.""" with ( patch( @@ -77,6 +105,16 @@ def mock_portainer_client() -> Generator[AsyncMock]: client.docker_system_df.return_value = DockerSystemDF.from_dict( load_json_value_fixture("docker_system_df.json", DOMAIN) ) + client.inspect_container.return_value = DockerInspect.from_dict( + load_json_value_fixture("container_inspect.json", DOMAIN) + ) + client.get_image.return_value = LocalImageInformation.from_dict( + load_json_value_fixture("local_image_information.json", DOMAIN) + ) + + client.restart_container = AsyncMock(return_value=None) + client.images_prune = AsyncMock(return_value=None) + client.container_recreate = AsyncMock(return_value=None) client.get_stacks.return_value = [ Stack.from_dict(stack) for stack in load_json_array_fixture("stacks.json", DOMAIN) diff --git a/tests/components/portainer/fixtures/container_inspect.json b/tests/components/portainer/fixtures/container_inspect.json new file mode 100644 index 000000000000..9560314a4d5c --- /dev/null +++ b/tests/components/portainer/fixtures/container_inspect.json @@ -0,0 +1,472 @@ +{ + "Id": "aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Created": "2025-02-17T17:43:39.64001363Z", + "Path": "/bin/sh", + "Args": ["-c", "exit 9"], + "State": { + "Status": "running", + "Running": true, + "Paused": false, + "Restarting": false, + "OOMKilled": false, + "Dead": false, + "Pid": 1234, + "ExitCode": 0, + "Error": "string", + "StartedAt": "2020-01-06T09:06:59.461876391Z", + "FinishedAt": "2020-01-06T09:07:59.461876391Z", + "Health": { + "Status": "healthy", + "FailingStreak": 0, + "Log": [ + { + "Start": "2020-01-04T10:44:24.496525531Z", + "End": "2020-01-04T10:45:21.364524523Z", + "ExitCode": 0, + "Output": "string" + } + ] + } + }, + "Image": "sha256:72297848456d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "ResolvConfPath": "/var/lib/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf/resolv.conf", + "HostnamePath": "/var/lib/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf/hostname", + "HostsPath": "/var/lib/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf/hosts", + "LogPath": "/var/lib/docker/containers/5b7c7e2b992aa426584ce6c47452756066be0e503a08b4516a433a54d2f69e59/5b7c7e2b992aa426584ce6c47452756066be0e503a08b4516a433a54d2f69e59-json.log", + "Name": "/funny_chatelet", + "RestartCount": 0, + "Driver": "overlayfs", + "Platform": "linux", + "ImageManifestDescriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96", + "size": 424, + "urls": ["http://example.com"], + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "org.opencontainers.image.base.digest": "sha256:0d0ef5c914d3ea700147da1bd050c59edb8bb12ca312f3800b29d7c8087eabd8", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2025-01-27T00:00:00Z", + "org.opencontainers.image.revision": "9fabb4bad5138435b01857e2fe9363e2dc5f6a79", + "org.opencontainers.image.source": "https://git.launchpad.net/cloud-images/+oci/ubuntu-base", + "org.opencontainers.image.url": "https://hub.docker.com/_/ubuntu", + "org.opencontainers.image.version": "24.04" + }, + "data": null, + "platform": { + "architecture": "arm", + "os": "windows", + "os.version": "10.0.19041.1165", + "os.features": ["win32k"], + "variant": "v7" + }, + "artifactType": null + }, + "MountLabel": "", + "ProcessLabel": "", + "AppArmorProfile": "", + "ExecIDs": [ + "b35395de42bc8abd327f9dd65d913b9ba28c74d2f0734eeeae84fa1c616a0fca", + "3fc1232e5cd20c8de182ed81178503dc6437f4e7ef12b52cc5e8de020652f1c4" + ], + "HostConfig": { + "CpuShares": 0, + "Memory": 0, + "CgroupParent": "string", + "BlkioWeight": 1000, + "BlkioWeightDevice": [ + { + "Path": "string", + "Weight": 0 + } + ], + "BlkioDeviceReadBps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "BlkioDeviceWriteBps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "BlkioDeviceReadIOps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "BlkioDeviceWriteIOps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpusetCpus": "0-3", + "CpusetMems": "string", + "Devices": [ + { + "PathOnHost": "/dev/deviceName", + "PathInContainer": "/dev/deviceName", + "CgroupPermissions": "mrw" + } + ], + "DeviceCgroupRules": ["c 13:* rwm"], + "DeviceRequests": [ + { + "Driver": "nvidia", + "Count": -1, + "DeviceIDs": ["0", "1", "GPU-fef8089b-4820-abfc-e83e-94318197576e"], + "Capabilities": [["gpu", "nvidia", "compute"]], + "Options": { + "property1": "string", + "property2": "string" + } + } + ], + "KernelMemoryTCP": 0, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": 100, + "NanoCpus": 0, + "OomKillDisable": true, + "Init": true, + "PidsLimit": 0, + "Ulimits": [ + { + "Name": "string", + "Soft": 0, + "Hard": 0 + } + ], + "CpuCount": 0, + "CpuPercent": 0, + "IOMaximumIOps": 0, + "IOMaximumBandwidth": 0, + "Binds": ["string"], + "ContainerIDFile": "", + "LogConfig": { + "Type": "local", + "Config": { + "max-file": "5", + "max-size": "10m" + } + }, + "NetworkMode": "string", + "PortBindings": { + "443/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "4443" + } + ], + "80/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + }, + { + "HostIp": "0.0.0.0", + "HostPort": "8080" + } + ], + "80/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + } + ], + "53/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "53" + } + ] + }, + "RestartPolicy": { + "Name": "", + "MaximumRetryCount": 0 + }, + "AutoRemove": true, + "VolumeDriver": "string", + "VolumesFrom": ["string"], + "Mounts": [ + { + "Target": "string", + "Source": "string", + "Type": "bind", + "ReadOnly": true, + "Consistency": "string", + "BindOptions": { + "Propagation": "private", + "NonRecursive": false, + "CreateMountpoint": false, + "ReadOnlyNonRecursive": false, + "ReadOnlyForceRecursive": false + }, + "VolumeOptions": { + "NoCopy": false, + "Labels": { + "property1": "string", + "property2": "string" + }, + "DriverConfig": { + "Name": "string", + "Options": { + "property1": "string", + "property2": "string" + } + }, + "Subpath": "dir-inside-volume/subdirectory" + }, + "ImageOptions": { + "Subpath": "dir-inside-image/subdirectory" + }, + "TmpfsOptions": { + "SizeBytes": 0, + "Mode": 0, + "Options": [["noexec"]] + } + } + ], + "ConsoleSize": [80, 64], + "Annotations": { + "property1": "string", + "property2": "string" + }, + "CapAdd": ["string"], + "CapDrop": ["string"], + "CgroupnsMode": "private", + "Dns": ["string"], + "DnsOptions": ["string"], + "DnsSearch": ["string"], + "ExtraHosts": ["string"], + "GroupAdd": ["string"], + "IpcMode": "string", + "Cgroup": "string", + "Links": ["string"], + "OomScoreAdj": 500, + "PidMode": "string", + "Privileged": true, + "PublishAllPorts": true, + "ReadonlyRootfs": true, + "SecurityOpt": ["string"], + "StorageOpt": { + "property1": "string", + "property2": "string" + }, + "Tmpfs": { + "property1": "string", + "property2": "string" + }, + "UTSMode": "string", + "UsernsMode": "string", + "ShmSize": 0, + "Sysctls": { + "net.ipv4.ip_forward": "1" + }, + "Runtime": "string", + "Isolation": "default", + "MaskedPaths": [ + "/proc/asound", + "/proc/acpi", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + "/sys/firmware", + "/sys/devices/virtual/powercap" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + }, + "GraphDriver": { + "Name": "overlay2", + "Data": { + "MergedDir": "/var/lib/docker/overlay2/ef749362d13333e65fc95c572eb525abbe0052e16e086cb64bc3b98ae9aa6d74/merged", + "UpperDir": "/var/lib/docker/overlay2/ef749362d13333e65fc95c572eb525abbe0052e16e086cb64bc3b98ae9aa6d74/diff", + "WorkDir": "/var/lib/docker/overlay2/ef749362d13333e65fc95c572eb525abbe0052e16e086cb64bc3b98ae9aa6d74/work" + } + }, + "SizeRw": "122880", + "SizeRootFs": "1653948416", + "Mounts": [ + { + "Type": "volume", + "Name": "myvolume", + "Source": "/var/lib/docker/volumes/myvolume/_data", + "Destination": "/usr/share/nginx/html/", + "Driver": "local", + "Mode": "z", + "RW": true, + "Propagation": "" + } + ], + "Config": { + "Hostname": "439f4e91bd1d", + "Domainname": "string", + "User": "123:456", + "AttachStdin": false, + "AttachStdout": true, + "AttachStderr": true, + "ExposedPorts": { + "80/tcp": {}, + "443/tcp": {} + }, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "Cmd": ["/bin/sh"], + "Healthcheck": { + "Test": ["string"], + "Interval": 0, + "Timeout": 0, + "Retries": 0, + "StartPeriod": 0, + "StartInterval": 0 + }, + "ArgsEscaped": false, + "Image": "example-image:1.0", + "Volumes": { + "property1": {}, + "property2": {} + }, + "WorkingDir": "/public/", + "Entrypoint": [], + "NetworkDisabled": true, + "MacAddress": "string", + "OnBuild": [], + "Labels": { + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value" + }, + "StopSignal": "SIGTERM", + "StopTimeout": 10, + "Shell": ["/bin/sh", "-c"] + }, + "NetworkSettings": { + "Bridge": "docker0", + "SandboxID": "9d12daf2c33f5959c8bf90aa513e4f65b561738661003029ec84830cd503a0c3", + "HairpinMode": false, + "LinkLocalIPv6Address": "", + "LinkLocalIPv6PrefixLen": "", + "Ports": { + "443/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "4443" + } + ], + "80/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + }, + { + "HostIp": "0.0.0.0", + "HostPort": "8080" + } + ], + "80/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + } + ], + "53/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "53" + } + ] + }, + "SandboxKey": "/var/run/docker/netns/8ab54b426c38", + "SecondaryIPAddresses": [ + { + "Addr": "string", + "PrefixLen": 0 + } + ], + "SecondaryIPv6Addresses": [ + { + "Addr": "string", + "PrefixLen": 0 + } + ], + "EndpointID": "b88f5b905aabf2893f3cbc4ee42d1ea7980bbc0a92e2c8922b1e1795298afb0b", + "Gateway": "172.17.0.1", + "GlobalIPv6Address": "2001:db8::5689", + "GlobalIPv6PrefixLen": 64, + "IPAddress": "172.17.0.4", + "IPPrefixLen": 16, + "IPv6Gateway": "2001:db8:2::100", + "MacAddress": "02:42:ac:11:00:04", + "Networks": { + "property1": { + "IPAMConfig": { + "IPv4Address": "172.20.30.33", + "IPv6Address": "2001:db8:abcd::3033", + "LinkLocalIPs": ["169.254.34.68", "fe80::3468"] + }, + "Links": ["container_1", "container_2"], + "MacAddress": "02:42:ac:11:00:04", + "Aliases": ["server_x", "server_y"], + "DriverOpts": { + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value" + }, + "GwPriority": [10], + "NetworkID": "08754567f1f40222263eab4102e1c733ae697e8e354aa9cd6e18d7402835292a", + "EndpointID": "b88f5b905aabf2893f3cbc4ee42d1ea7980bbc0a92e2c8922b1e1795298afb0b", + "Gateway": "172.17.0.1", + "IPAddress": "172.17.0.4", + "IPPrefixLen": 16, + "IPv6Gateway": "2001:db8:2::100", + "GlobalIPv6Address": "2001:db8::5689", + "GlobalIPv6PrefixLen": 64, + "DNSNames": ["foobar", "server_x", "server_y", "my.ctr"] + }, + "property2": { + "IPAMConfig": { + "IPv4Address": "172.20.30.33", + "IPv6Address": "2001:db8:abcd::3033", + "LinkLocalIPs": ["169.254.34.68", "fe80::3468"] + }, + "Links": ["container_1", "container_2"], + "MacAddress": "02:42:ac:11:00:04", + "Aliases": ["server_x", "server_y"], + "DriverOpts": { + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value" + }, + "GwPriority": [10], + "NetworkID": "08754567f1f40222263eab4102e1c733ae697e8e354aa9cd6e18d7402835292a", + "EndpointID": "b88f5b905aabf2893f3cbc4ee42d1ea7980bbc0a92e2c8922b1e1795298afb0b", + "Gateway": "172.17.0.1", + "IPAddress": "172.17.0.4", + "IPPrefixLen": 16, + "IPv6Gateway": "2001:db8:2::100", + "GlobalIPv6Address": "2001:db8::5689", + "GlobalIPv6PrefixLen": 64, + "DNSNames": ["foobar", "server_x", "server_y", "my.ctr"] + } + } + } +} diff --git a/tests/components/portainer/fixtures/local_image_information.json b/tests/components/portainer/fixtures/local_image_information.json new file mode 100644 index 000000000000..dd8d2d7060c1 --- /dev/null +++ b/tests/components/portainer/fixtures/local_image_information.json @@ -0,0 +1,90 @@ +{ + "Id": "sha256:ec3f0931a6e6b6855d76b2d7b0be30e81860baccd891b2e243280bf1cd8ad710", + "RepoTags": [ + "example:1.0", + "example:latest", + "example:stable", + "internal.registry.example.com:5000/example:1.0" + ], + "RepoDigests": [ + "example@sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb", + "internal.registry.example.com:5000/example@sha256:b69959407d21e8a062e0416bf13405bb2b71ed7a84dde4158ebafacfa06f5578" + ], + "Parent": "", + "Comment": "", + "Created": "2022-02-04T21:20:12.497794809Z", + "Container": "65974bc86f1770ae4bff79f651ebdbce166ae9aada632ee3fa9af3a264911735", + "ContainerConfig": { + "Hostname": "439f4e91bd1d", + "Domainname": "string", + "User": "string", + "AttachStdin": false, + "AttachStdout": true, + "AttachStderr": true, + "ExposedPorts": { + "80/tcp": {}, + "443/tcp": {} + }, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [], + "Cmd": [], + "Healthcheck": {}, + "ArgsEscaped": false, + "Image": "example-image:1.0", + "Volumes": {}, + "WorkingDir": "/public/", + "Entrypoint": [], + "NetworkDisabled": true, + "MacAddress": "string", + "OnBuild": [], + "Labels": {}, + "StopSignal": "SIGTERM", + "StopTimeout": 10, + "Shell": [] + }, + "DockerVersion": "20.10.7", + "Author": "", + "Config": { + "Hostname": "", + "Domainname": "", + "User": "web:web", + "AttachStdin": false, + "AttachStdout": false, + "AttachStderr": false, + "ExposedPorts": {}, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [], + "Cmd": [], + "Healthcheck": {}, + "ArgsEscaped": true, + "Image": "", + "Volumes": {}, + "WorkingDir": "/public/", + "Entrypoint": [], + "OnBuild": [], + "Labels": {}, + "StopSignal": "SIGTERM", + "Shell": [] + }, + "Architecture": "arm", + "Variant": "v7", + "Os": "linux", + "OsVersion": "", + "Size": 1239828, + "VirtualSize": 1239828, + "GraphDriver": { + "Name": "overlay2", + "Data": {} + }, + "RootFS": { + "Type": "layers", + "Layers": [] + }, + "Metadata": { + "LastTagTime": "2022-02-28T14:40:02.623929178Z" + } +} diff --git a/tests/components/portainer/snapshots/test_update.ambr b/tests/components/portainer/snapshots/test_update.ambr new file mode 100644 index 000000000000..7f3bb09835ff --- /dev/null +++ b/tests/components/portainer/snapshots/test_update.ambr @@ -0,0 +1,373 @@ +# serializer version: 1 +# name: test_update_entities[update.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05', + : None, + }), + 'context': , + 'entity_id': 'update.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.focused_einstein_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.focused_einstein_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_focused_einstein_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.focused_einstein_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'focused_einstein Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'focused_einstein', + : None, + }), + 'context': , + 'entity_id': 'update.focused_einstein_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.funny_chatelet_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.funny_chatelet_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.funny_chatelet_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'funny_chatelet Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : 'sha256:newdigest123456789', + : None, + : None, + : None, + : , + : 'funny_chatelet', + : None, + }), + 'context': , + 'entity_id': 'update.funny_chatelet_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_entities[update.practical_morse_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.practical_morse_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_practical_morse_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.practical_morse_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'practical_morse Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'practical_morse', + : None, + }), + 'context': , + 'entity_id': 'update.practical_morse_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.serene_banach_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.serene_banach_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_serene_banach_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.serene_banach_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'serene_banach Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'serene_banach', + : None, + }), + 'context': , + 'entity_id': 'update.serene_banach_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.stoic_turing_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.stoic_turing_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_stoic_turing_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.stoic_turing_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'stoic_turing Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'stoic_turing', + : None, + }), + 'context': , + 'entity_id': 'update.stoic_turing_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 9ea93c1f9fa1..aa435a522e2f 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -22,6 +22,8 @@ from homeassistant.const import ( CONF_HOST, CONF_URL, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -181,6 +183,46 @@ async def test_migration_v3_to_v5( assert entity_after.unique_id == f"{entry.entry_id}_1_adguard_container" +async def test_unload_entry( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_unload_entry.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + mock_portainer_watcher.stop.assert_called_once() + + +async def test_watcher_start_stop( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that watcher starts and stops on HA events.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + mock_portainer_watcher.start.assert_called_once() + + mock_portainer_watcher.stop.reset_mock() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_portainer_watcher.stop.assert_called_once() + + async def test_migration_v4_to_v5( hass: HomeAssistant, mock_portainer_client: AsyncMock, diff --git a/tests/components/portainer/test_update.py b/tests/components/portainer/test_update.py new file mode 100644 index 000000000000..c46d8b4f4f63 --- /dev/null +++ b/tests/components/portainer/test_update.py @@ -0,0 +1,140 @@ +"""Tests for the Portainer update platform.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "update.funny_chatelet_image_update_available" + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_portainer_client") +async def test_update_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test for all Portainer update entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +async def test_update_install( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful container image update installation.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "update", + "install", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + mock_portainer_client.container_recreate.assert_called_once() + + +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + (PortainerAuthenticationError("auth"), "invalid_auth_no_details"), + (PortainerConnectionError("conn"), "cannot_connect_no_details"), + ], +) +async def test_update_install_errors( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + translation_key: str, +) -> None: + """Test container image update install error handling.""" + mock_portainer_client.container_recreate.side_effect = exception + + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "update", + "install", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + +async def test_update_using_cache( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the update entity uses the cache and doesn't call the API.""" + mock_portainer_watcher.last_check = 1234 + + with ( + patch( + "homeassistant.components.portainer.coordinator.time.monotonic", + return_value=1235.0, + ), + patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ), + ): + await setup_integration(hass, mock_config_entry) + + # Reset call counts, since it needs to be measured what happens in this sequence + mock_portainer_client.inspect_container.reset_mock() + mock_portainer_client.get_image.reset_mock() + + # Trigger a refresh, but it should use the cache + await hass.services.async_call( + "update", + "install", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + mock_portainer_client.inspect_container.assert_not_called() + mock_portainer_client.get_image.assert_not_called() From 4cc8f60a00ef83aebd80524757a5cb5f8dd7b458 Mon Sep 17 00:00:00 2001 From: fdebrus <33791533+fdebrus@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:17:40 +0200 Subject: [PATCH 288/707] Add stale device support to Vistapool (#175866) Co-authored-by: Claude --- .../components/vistapool/__init__.py | 24 ++++++ tests/components/vistapool/test_init.py | 81 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index d9b608897f93..1f34877ab995 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -20,6 +20,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -96,6 +97,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> await coordinator.async_shutdown() raise + # Catch pools removed from the account while Home Assistant was offline; the + # first live snapshot is a no-op so it wouldn't clean these up. + _async_remove_stale_devices(hass, entry, set(pools)) + def _on_user_pools_snapshot(pool_ids: list[str]) -> None: """Bridge the Firestore snapshot from the watch thread to the HA loop.""" hass.loop.call_soon_threadsafe(_schedule_reconcile, pool_ids) @@ -136,6 +141,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) - return unload_ok +@callback +def _async_remove_stale_devices( + hass: HomeAssistant, entry: VistapoolConfigEntry, valid_pool_ids: set[str] +) -> None: + """Remove registry devices for pools no longer present on the account.""" + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + pool_id = next((i[1] for i in device.identifiers if i[0] == DOMAIN), None) + if pool_id is not None and pool_id not in valid_pool_ids: + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + async def _async_initial_refresh( coordinator: VistapoolDataUpdateCoordinator, *, first: bool ) -> None: @@ -210,3 +229,8 @@ async def _async_reconcile_pools( async_dispatcher_send( hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", coordinator ) + + if stale := current - fetched: + for pool_id in stale: + await entry.runtime_data.coordinators.pop(pool_id).async_shutdown() + _async_remove_stale_devices(hass, entry, fetched) diff --git a/tests/components/vistapool/test_init.py b/tests/components/vistapool/test_init.py index 8f66760c0c57..1522bb60bd6b 100644 --- a/tests/components/vistapool/test_init.py +++ b/tests/components/vistapool/test_init.py @@ -17,6 +17,7 @@ from tests.common import MockConfigEntry _SECOND_POOL_ID = "ZYXWVU9876543210" _SECOND_POOL_NAME = "Spa" +_THIRD_POOL_ID = "QQQQQQ1111111111" async def test_setup_entry( @@ -200,6 +201,86 @@ async def test_user_pools_snapshot_no_change_is_noop( mock_vistapool_client.get_pools.assert_not_called() +async def test_user_pools_snapshot_removes_stale_pool( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a user-pools snapshot missing a pool removes its entities and device.""" + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is not None + + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, _SECOND_POOL_ID)}) + is None + ) + + +async def test_user_pools_snapshot_drops_stale_even_if_get_pools_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test stale pool removal still runs when get_pools() raises during reconcile.""" + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("sensor.spa_temperature") is not None + + mock_vistapool_client.get_pools.side_effect = AquariteError("name lookup down") + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID, _THIRD_POOL_ID]) + await hass.async_block_till_done() + + # New pool skipped (no name available), stale pool removed regardless. + assert hass.states.get("sensor.spa_temperature") is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, _THIRD_POOL_ID)}) is None + ) + + +async def test_setup_prunes_devices_removed_while_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup removes a leftover device for a pool no longer on the account.""" + mock_config_entry.add_to_hass(hass) + stale_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, _SECOND_POOL_ID)}, + ) + assert stale_device is not None + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, MOCK_POOL_ID)}) + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, _SECOND_POOL_ID)}) + is None + ) + + async def test_apply_optimistic_creates_missing_intermediate_dicts( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From bb7433e10e02a2159d5084c1404e07ae913012b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Wed, 8 Jul 2026 20:33:05 +0200 Subject: [PATCH 289/707] Add service to perform a combined move and tilt of a WMS cover (#174570) Co-authored-by: Norbert Rittel Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/wmspro/__init__.py | 13 +- homeassistant/components/wmspro/const.py | 1 + homeassistant/components/wmspro/cover.py | 12 ++ homeassistant/components/wmspro/icons.json | 5 + homeassistant/components/wmspro/services.py | 37 +++++ homeassistant/components/wmspro/services.yaml | 25 +++ homeassistant/components/wmspro/strings.json | 24 ++- tests/components/wmspro/conftest.py | 17 +- .../wmspro/snapshots/test_number.ambr | 6 +- tests/components/wmspro/test_services.py | 147 ++++++++++++++++++ 10 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/wmspro/services.py create mode 100644 homeassistant/components/wmspro/services.yaml create mode 100644 tests/components/wmspro/test_services.py diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py index 2553cf2a9ca1..71c2310a33f5 100644 --- a/homeassistant/components/wmspro/__init__.py +++ b/homeassistant/components/wmspro/__init__.py @@ -10,12 +10,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import STORAGE_DIR -from homeassistant.helpers.typing import UNDEFINED +from homeassistant.helpers.typing import UNDEFINED, ConfigType from .const import DOMAIN, MANUFACTURER +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BUTTON, @@ -28,6 +29,8 @@ PLATFORMS: list[Platform] = [ type WebControlProConfigEntry = ConfigEntry[WebControlPro] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + def _build_storage_config_dir( hass: HomeAssistant, entry: WebControlProConfigEntry @@ -36,6 +39,12 @@ def _build_storage_config_dir( return Path(hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.entry_id}")) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + async_setup_services(hass) + return True + + async def async_setup_entry( hass: HomeAssistant, entry: WebControlProConfigEntry ) -> bool: diff --git a/homeassistant/components/wmspro/const.py b/homeassistant/components/wmspro/const.py index d92534d9e46f..94d15992ab1d 100644 --- a/homeassistant/components/wmspro/const.py +++ b/homeassistant/components/wmspro/const.py @@ -7,3 +7,4 @@ ATTRIBUTION = "Data provided by WMS WebControl pro API" MANUFACTURER = "WAREMA Renkhoff SE" BRIGHTNESS_SCALE = (1, 100) +SERVICE_SET_COVER_POSITION_AND_TILT = "set_cover_position_and_tilt" diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 8b63ca78d030..92d068c8f44b 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -206,3 +206,15 @@ class WebControlProSlatRotate(WebControlProSlat): # with the close position the slat is perpendicular to the ground. # This position will block the light best. await action(rotation=action.maxValue) + + async def async_set_cover_position_and_tilt(self, **kwargs: Any) -> None: + """Handle the service action call to set cover position and tilt.""" + action_drive = self._dest.action(self._drive_action_desc) + action_list = action_drive.prep(percentage=100 - kwargs[ATTR_POSITION]) + action_tilt = self._dest.action(self._tilt_action_desc) + rotation = percentage_to_ranged_value( + (action_tilt.minValue, action_tilt.maxValue), + 100 - kwargs[ATTR_TILT_POSITION], + ) + action_list += action_tilt.prep(rotation=rotation) + await action_list() diff --git a/homeassistant/components/wmspro/icons.json b/homeassistant/components/wmspro/icons.json index d7601026f573..ca5d0810f9ee 100644 --- a/homeassistant/components/wmspro/icons.json +++ b/homeassistant/components/wmspro/icons.json @@ -16,5 +16,10 @@ "default": "mdi:rotate-left" } } + }, + "services": { + "set_cover_position_and_tilt": { + "service": "mdi:blinds-horizontal" + } } } diff --git a/homeassistant/components/wmspro/services.py b/homeassistant/components/wmspro/services.py new file mode 100644 index 000000000000..389ce0de4304 --- /dev/null +++ b/homeassistant/components/wmspro/services.py @@ -0,0 +1,37 @@ +"""Services for WMS WebControl pro.""" + +import voluptuous as vol + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_SET_COVER_POSITION_AND_TILT + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for WMS WebControl pro.""" + service.async_register_platform_entity_service( + hass, + service_domain=DOMAIN, + service_name=SERVICE_SET_COVER_POSITION_AND_TILT, + entity_domain=COVER_DOMAIN, + func="async_set_cover_position_and_tilt", + required_features=[ + CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_TILT_POSITION + ], + schema={ + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Required(ATTR_TILT_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + }, + ) diff --git a/homeassistant/components/wmspro/services.yaml b/homeassistant/components/wmspro/services.yaml new file mode 100644 index 000000000000..fd444e5c7697 --- /dev/null +++ b/homeassistant/components/wmspro/services.yaml @@ -0,0 +1,25 @@ +set_cover_position_and_tilt: + target: + entity: + integration: wmspro + domain: cover + supported_features: + - - cover.CoverEntityFeature.SET_POSITION + - cover.CoverEntityFeature.SET_TILT_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + mode: slider + unit_of_measurement: "%" + tilt_position: + required: true + selector: + number: + min: 0 + max: 100 + mode: slider + unit_of_measurement: "%" diff --git a/homeassistant/components/wmspro/strings.json b/homeassistant/components/wmspro/strings.json index 9d0b2c04a4d4..988d20e1efca 100644 --- a/homeassistant/components/wmspro/strings.json +++ b/homeassistant/components/wmspro/strings.json @@ -25,7 +25,7 @@ "entity": { "button": { "rotation-reset": { - "name": "Reset Rotation" + "name": "Reset rotation" } }, "cover": { @@ -38,14 +38,30 @@ "name": "Rotation" }, "rotation-max": { - "name": "Maximum Rotation" + "name": "Maximum rotation" }, "rotation-min": { - "name": "Minimum Rotation" + "name": "Minimum rotation" }, "rotation-raw": { - "name": "Raw Rotation" + "name": "Raw rotation" } } + }, + "services": { + "set_cover_position_and_tilt": { + "description": "Moves the cover and tilt to the target position simultaneously, preventing cancellation of individual movements.", + "fields": { + "position": { + "description": "Target vertical position. 0 means closed, 100 means fully open.", + "name": "Position" + }, + "tilt_position": { + "description": "Target tilt position.", + "name": "Tilt position" + } + }, + "name": "Set cover position and tilt" + } } } diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index 8e076755ff10..35bcaa8658ed 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable, Generator from unittest.mock import AsyncMock, patch import pytest +from wmspro.action import Action, ActionList from homeassistant.components.wmspro.const import DOMAIN from homeassistant.const import CONF_HOST @@ -118,9 +119,11 @@ def mock_action_call() -> Generator[Callable]: async def fake_call(self, **kwargs): self._update_params(kwargs) - with patch( - "wmspro.action.Action.__call__", - fake_call, + with patch.object( + Action, + "__call__", + side_effect=fake_call, + autospec=True, ) as mock_action_call: yield mock_action_call @@ -135,9 +138,11 @@ def mock_action_list_call() -> Generator[Callable]: dest = self._control.dests[args["destinationId"]] await dest.actions[args["actionId"]](**args["parameters"]) - with patch( - "wmspro.action.ActionList.__call__", - fake_list_call, + with patch.object( + ActionList, + "__call__", + side_effect=fake_list_call, + autospec=True, ) as mock_action_list_call: yield mock_action_list_call diff --git a/tests/components/wmspro/snapshots/test_number.ambr b/tests/components/wmspro/snapshots/test_number.ambr index ffc1e1db4b27..bfa66e925083 100644 --- a/tests/components/wmspro/snapshots/test_number.ambr +++ b/tests/components/wmspro/snapshots/test_number.ambr @@ -3,7 +3,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by WMS WebControl pro API', - : 'Keuken alle Maximum Rotation', + : 'Keuken alle Maximum rotation', : 127, : 0, : , @@ -21,7 +21,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by WMS WebControl pro API', - : 'Keuken alle Minimum Rotation', + : 'Keuken alle Minimum rotation', : 0, : -127, : , @@ -39,7 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by WMS WebControl pro API', - : 'Keuken alle Raw Rotation', + : 'Keuken alle Raw rotation', : 127, : -127, : , diff --git a/tests/components/wmspro/test_services.py b/tests/components/wmspro/test_services.py new file mode 100644 index 000000000000..9062087badee --- /dev/null +++ b/tests/components/wmspro/test_services.py @@ -0,0 +1,147 @@ +"""Test wmspro integration services.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, +) +from homeassistant.components.wmspro.const import ( + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OPEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_config_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status"), + [("config_prod_slat_rotate.json", "status_prod_slat_rotate.json")], + indirect=True, +) +async def test_set_cover_position_and_tilt_service_is_registered( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, +) -> None: + """Test that set_cover_position_and_tilt service is registered.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 + + assert hass.services.has_service(DOMAIN, SERVICE_SET_COVER_POSITION_AND_TILT) + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_id"), + [ + ( + "config_prod_slat_rotate.json", + "status_prod_slat_rotate.json", + "cover.zonwering_begane_grond_keuken_alle", + ), + ], + indirect=["mock_hub_configuration", "mock_hub_status"], +) +async def test_set_cover_position_and_tilt_service_executes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, + mock_action_call: AsyncMock, + mock_action_list_call: AsyncMock, + entity_id: str, +) -> None: + """Test set_cover_position_and_tilt updates position and tilt in one action call.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.attributes[ATTR_CURRENT_POSITION] == 0 + assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, + { + ATTR_ENTITY_ID: entity.entity_id, + ATTR_POSITION: 30, + ATTR_TILT_POSITION: 80, + }, + blocking=True, + ) + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.state == STATE_OPEN + assert entity.attributes[ATTR_CURRENT_POSITION] == 30 + assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 80 + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 2 + assert len(mock_action_list_call.mock_calls) == before_action_list + 1 + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_id"), + [ + ( + "config_prod_awning_dimmer.json", + "status_prod_awning.json", + "cover.terrasse_markise", + ), + ], + indirect=["mock_hub_configuration", "mock_hub_status"], +) +async def test_set_cover_position_and_tilt_unsupported_entity_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, + entity_id: str, +) -> None: + """Test set_cover_position_and_tilt raises for entities without tilt support.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) >= 1 + + before = len(mock_hub_status.mock_calls) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, + { + ATTR_ENTITY_ID: entity_id, + ATTR_POSITION: 30, + ATTR_TILT_POSITION: 80, + }, + blocking=True, + ) + + assert len(mock_hub_status.mock_calls) == before From 333998f714a8ab430f150963ff1bad970b64f9bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:33:48 +0200 Subject: [PATCH 290/707] Use AlarmControlPanelEntityStateAttribute enum in ELK-M1 (#175871) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/elkm1/alarm_control_panel.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 289de2c7133a..e85a9a303b6c 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -10,9 +10,9 @@ from elkm1_lib.keypads import Keypad import voluptuous as vol from homeassistant.components.alarm_control_panel import ( - ATTR_CHANGED_BY, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, + AlarmControlPanelEntityStateAttribute, AlarmControlPanelState, CodeFormat, ) @@ -136,8 +136,10 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): self._changed_by_time = last_state.attributes[ATTR_CHANGED_BY_TIME] if ATTR_CHANGED_BY_ID in last_state.attributes: self._changed_by_id = last_state.attributes[ATTR_CHANGED_BY_ID] - if ATTR_CHANGED_BY in last_state.attributes: - self._changed_by = last_state.attributes[ATTR_CHANGED_BY] + if AlarmControlPanelEntityStateAttribute.CHANGED_BY in last_state.attributes: + self._changed_by = last_state.attributes[ + AlarmControlPanelEntityStateAttribute.CHANGED_BY + ] def _watch_keypad(self, keypad: Element, changeset: dict[str, Any]) -> None: assert isinstance(keypad, Keypad) From 3e0aa0b81df4bc3eacca6d7a9ef0c3828ec02fb2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:34:10 +0200 Subject: [PATCH 291/707] Use state attribute enums in emulated_hue (#175870) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/emulated_hue/hue_api.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 103ab28f480e..ba0de3a35ac0 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -50,7 +50,6 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, @@ -62,6 +61,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + EntityStateAttribute, ) from homeassistant.core import Event, EventStateChangedData, State from homeassistant.helpers.event import async_track_state_change_event @@ -382,9 +382,16 @@ class HueOneLightChangeView(HomeAssistantView): return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) # Get the entity's supported features - entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + entity_features = entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if entity.domain == light.DOMAIN: - color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or [] + color_modes = ( + entity.attributes.get( + light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES + ) + or [] + ) # Parse the request parsed: dict[str, Any] = { @@ -769,7 +776,10 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or [] + color_modes = ( + state.attributes.get(light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES) + or [] + ) unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) @@ -865,7 +875,7 @@ def state_supports_hue_brightness( return light.brightness_supported(color_modes) if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)): return False - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + features = state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) enum = ENTITY_FEATURES_BY_DOMAIN[domain] features = enum(features) if type(features) is int else features return required_feature in features From d93c33e29c56f291152d180438a4db7df6b0df2f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 22:35:25 +0200 Subject: [PATCH 292/707] Sure Pet Care add service translations (#175769) --- .../components/surepetcare/services.py | 18 ++++++++++++------ .../components/surepetcare/strings.json | 8 ++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/surepetcare/services.py b/homeassistant/components/surepetcare/services.py index 74ec28a91aa9..6ff3d3c41c3a 100644 --- a/homeassistant/components/surepetcare/services.py +++ b/homeassistant/components/surepetcare/services.py @@ -29,9 +29,12 @@ def async_setup_services(hass: HomeAssistant) -> None: hass, DOMAIN, None ) coordinator = entry.runtime_data - flap_id = call.data[ATTR_FLAP_ID] - if flap_id not in coordinator.data: - raise ServiceValidationError(f"Unknown Sure Petcare flap ID: {flap_id}") + if call.data[ATTR_FLAP_ID] not in coordinator.data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_flap_id", + translation_placeholders={"flap_id": call.data[ATTR_FLAP_ID]}, + ) await coordinator.handle_set_lock_state(call) async def handle_set_pet_location(call: ServiceCall) -> None: @@ -40,9 +43,12 @@ def async_setup_services(hass: HomeAssistant) -> None: hass, DOMAIN, None ) coordinator = entry.runtime_data - pet_name = call.data[ATTR_PET_NAME] - if pet_name not in coordinator.get_pets(): - raise ServiceValidationError(f"Unknown Sure Petcare pet: {pet_name}") + if call.data[ATTR_PET_NAME] not in coordinator.get_pets(): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_pet_name", + translation_placeholders={"pet_name": call.data[ATTR_PET_NAME]}, + ) await coordinator.handle_set_pet_location(call) hass.services.async_register( diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json index 890c97ba3e57..ecb60ced5486 100644 --- a/homeassistant/components/surepetcare/strings.json +++ b/homeassistant/components/surepetcare/strings.json @@ -25,6 +25,14 @@ } } }, + "exceptions": { + "invalid_flap_id": { + "message": "Unknown Sure Petcare flap ID: {flap_id}" + }, + "invalid_pet_name": { + "message": "Unknown Sure Petcare pet: {pet_name}" + } + }, "services": { "set_lock_state": { "description": "Sets lock state.", From 739c4af8c634d4017cd3b96aa28e66dc9139189f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 22:35:50 +0200 Subject: [PATCH 293/707] Move Genius Hub services to async_setup (#175476) --- .../components/geniushub/__init__.py | 79 +++--------------- homeassistant/components/geniushub/const.py | 6 ++ homeassistant/components/geniushub/entity.py | 2 +- .../components/geniushub/services.py | 81 +++++++++++++++++++ .../components/geniushub/strings.json | 8 ++ homeassistant/components/geniushub/switch.py | 3 +- 6 files changed, 109 insertions(+), 70 deletions(-) create mode 100644 homeassistant/components/geniushub/services.py diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index f9d6972f9c25..e3ce4bd21438 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -5,12 +5,9 @@ import logging import aiohttp from geniushubclient import GeniusHub -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_TEMPERATURE, CONF_HOST, CONF_MAC, CONF_PASSWORD, @@ -18,14 +15,15 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import setup_service_functions _LOGGER = logging.getLogger(__name__) @@ -34,31 +32,6 @@ SCAN_INTERVAL = timedelta(seconds=60) MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" -ATTR_ZONE_MODE = "mode" -ATTR_DURATION = "duration" - -SVC_SET_ZONE_MODE = "set_zone_mode" -SVC_SET_ZONE_OVERRIDE = "set_zone_override" - -SET_ZONE_MODE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_ZONE_MODE): vol.In(["off", "timer", "footprint"]), - } -) -SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_TEMPERATURE): vol.All( - vol.Coerce(float), vol.Range(min=4, max=28) - ), - vol.Optional(ATTR_DURATION): vol.All( - cv.time_period, - vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)), - ), - } -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -70,6 +43,14 @@ PLATFORMS = [ type GeniusHubConfigEntry = ConfigEntry[GeniusBroker] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Genius Hub services.""" + setup_service_functions(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> bool: """Create a Genius Hub system.""" @@ -111,49 +92,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) - setup_service_functions(hass, broker) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -@callback -def setup_service_functions(hass: HomeAssistant, broker): - """Set up the service functions.""" - - @verify_domain_control(DOMAIN) - async def set_zone_mode(call: ServiceCall) -> None: - """Set the system mode.""" - entity_id = call.data[ATTR_ENTITY_ID] - - registry = er.async_get(hass) - registry_entry = registry.async_get(entity_id) - - if registry_entry is None or registry_entry.platform != DOMAIN: - raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity") - - if registry_entry.domain != "climate": - raise ValueError(f"'{entity_id}' is not an {DOMAIN} zone") - - payload = { - "unique_id": registry_entry.unique_id, - "service": call.service, - "data": call.data, - } - - async_dispatcher_send(hass, DOMAIN, payload) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA - ) - - class GeniusBroker: """Container for geniushub client and data.""" diff --git a/homeassistant/components/geniushub/const.py b/homeassistant/components/geniushub/const.py index 4601eca5f9bd..e62caa15fd45 100644 --- a/homeassistant/components/geniushub/const.py +++ b/homeassistant/components/geniushub/const.py @@ -6,6 +6,12 @@ from homeassistant.const import Platform DOMAIN = "geniushub" +ATTR_ZONE_MODE = "mode" +ATTR_DURATION = "duration" + +SVC_SET_ZONE_MODE = "set_zone_mode" +SVC_SET_ZONE_OVERRIDE = "set_zone_override" + SCAN_INTERVAL = timedelta(seconds=60) SENSOR_PREFIX = "Genius" diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py index 741adac6c014..c9d7085b14c8 100644 --- a/homeassistant/components/geniushub/entity.py +++ b/homeassistant/components/geniushub/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -from . import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE +from .const import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE # temperature is repeated here, as it gives access to high-precision temps GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"] diff --git a/homeassistant/components/geniushub/services.py b/homeassistant/components/geniushub/services.py new file mode 100644 index 000000000000..ac22ee8c5283 --- /dev/null +++ b/homeassistant/components/geniushub/services.py @@ -0,0 +1,81 @@ +"""Support for Genius Hub services.""" + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service import verify_domain_control + +from .const import ( + ATTR_DURATION, + ATTR_ZONE_MODE, + DOMAIN, + SVC_SET_ZONE_MODE, + SVC_SET_ZONE_OVERRIDE, +) + +SET_ZONE_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ZONE_MODE): vol.In(["off", "timer", "footprint"]), + } +) +SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Range(min=4, max=28) + ), + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)), + ), + } +) + + +@callback +def setup_service_functions(hass: HomeAssistant) -> None: + """Set up the service functions.""" + + @verify_domain_control(DOMAIN) + async def set_zone_mode(call: ServiceCall) -> None: + """Set the system mode.""" + entity_id = call.data[ATTR_ENTITY_ID] + + registry = er.async_get(hass) + registry_entry = registry.async_get(entity_id) + + if registry_entry is None or registry_entry.platform != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entity_id", + translation_placeholders={"entity_id": entity_id}, + ) + + if registry_entry.domain != "climate": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_zone", + translation_placeholders={"entity_id": entity_id}, + ) + + payload = { + "unique_id": registry_entry.unique_id, + "service": call.service, + "data": call.data, + } + + async_dispatcher_send(hass, DOMAIN, payload) + + hass.services.async_register( + DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA + ) diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index 57521666ffb6..a90df19191fd 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -34,6 +34,14 @@ } }, + "exceptions": { + "invalid_entity_id": { + "message": "Unknown Genius Hub entity: {entity_id}" + }, + "invalid_zone": { + "message": "Unknown Genius Hub zone: {entity_id}" + } + }, "services": { "set_switch_override": { "description": "Overrides switch for a given duration.", diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 0abb997612f5..d22ec6c6c833 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -11,7 +11,8 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import ATTR_DURATION, GeniusHubConfigEntry +from . import GeniusHubConfigEntry +from .const import ATTR_DURATION from .entity import GeniusZone GH_ON_OFF_ZONE = "on / off" From c33f0bce4c5e5652f6a0aae55e052ad5bef1a0d9 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 8 Jul 2026 22:37:27 +0200 Subject: [PATCH 294/707] Deprecate firing events to event bus in HTML5 integration (#168725) --- homeassistant/components/html5/issue.py | 30 ++++++++ homeassistant/components/html5/notify.py | 9 ++- homeassistant/components/html5/strings.json | 4 + tests/components/html5/test_event.py | 82 ++++++++++++++++++++- 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py index 66c11f5c7421..37cb84fbff7f 100644 --- a/homeassistant/components/html5/issue.py +++ b/homeassistant/components/html5/issue.py @@ -52,3 +52,33 @@ def deprecated_dismiss_action_call(hass: HomeAssistant) -> None: "new_action": "html5.dismiss_message", }, ) + + +@callback +def deprecated_event_bus(hass: HomeAssistant, event: str) -> None: + """Raise a deprecation issue for listeners on the event bus.""" + + if listeners := hass.bus.async_listeners().get(event): + async_create_issue( + hass, + DOMAIN, + f"deprecated_event_bus_{event}", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_event_bus", + translation_placeholders={ + "event": event, + "listeners": str(listeners), + "example_yaml": """```yaml +triggers: + - trigger: event.received + target: + entity_id: event.my_device + options: + event_type: + - received +``` +""", + }, + ) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 00e5762b3ddc..1e6c1b743035 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -60,7 +60,11 @@ from .const import ( SERVICE_DISMISS, ) from .entity import HTML5Entity, Registration -from .issue import deprecated_dismiss_action_call, deprecated_notify_action_call +from .issue import ( + deprecated_dismiss_action_call, + deprecated_event_bus, + deprecated_notify_action_call, +) _LOGGER = logging.getLogger(__name__) @@ -409,6 +413,9 @@ class HTML5PushCallbackView(HomeAssistantView): event_payload[ATTR_TYPE], event_payload, ) + + deprecated_event_bus(hass, event_name) + return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]}) diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 3dea89e6ead2..1bc8941c65e6 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -55,6 +55,10 @@ "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action}` action instead.", "title": "[%key:component::html5::issues::deprecated_notify_action::title%]" }, + "deprecated_event_bus": { + "description": "Detected **{listeners}** listener(s) for the event `{event}`.\n\nThe HTML5 Push Notifications integration firing events on the event bus is deprecated and this functionality will be removed in a future release.\n\nPlease update your automations and scripts to use the event entities instead.\n\n## Example automation:\n\n{example_yaml}", + "title": "Detected use of deprecated event {event}" + }, "deprecated_notify_action": { "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action_1}` or `{new_action_2}` actions instead.", "title": "Detected use of deprecated action {action}" diff --git a/tests/components/html5/test_event.py b/tests/components/html5/test_event.py index cd4be641f355..25e783df5e6b 100644 --- a/tests/components/html5/test_event.py +++ b/tests/components/html5/test_event.py @@ -9,12 +9,13 @@ from aiohttp.hdrs import AUTHORIZATION import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.html5.const import DOMAIN from homeassistant.components.html5.notify import ATTR_ACTION, ATTR_TAG, ATTR_TYPE from homeassistant.components.notify import ATTR_DATA, ATTR_TARGET from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .test_notify import SUBSCRIPTION_1 @@ -118,3 +119,82 @@ async def test_events( assert state.attributes.get("action") == event_payload.get(ATTR_ACTION) assert state.attributes.get("tag") == event_payload[ATTR_TAG] assert state.attributes.get("customKey") == event_payload[ATTR_DATA]["customKey"] + + +@pytest.mark.parametrize("event_type", ["clicked", "received", "closed"]) +@pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") +async def test_deprecation_event_bus( + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, + event_type: str, +) -> None: + """Test deprecation of events on the event bus.""" + load_config.return_value = {"device": SUBSCRIPTION_1} + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + config_entry.async_on_unload( + hass.bus.async_listen(f"html5_notification.{event_type}", lambda _: None) + ) + client = await hass_client() + + resp = await client.post( + "/api/notify.html5/callback", + json={"type": event_type, "tag": "12345", "target": "device"}, + headers={AUTHORIZATION: "Bearer JWT"}, + ) + + assert resp.status == HTTPStatus.OK + body = await resp.json() + assert body == {"event": event_type, "status": "ok"} + + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_event_bus_html5_notification.{event_type}", + ) + + +@pytest.mark.parametrize("event_type", ["clicked", "received", "closed"]) +@pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") +async def test_deprecation_event_bus_no_listeners( + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, + event_type: str, +) -> None: + """Test no issue is created when there are no listeners.""" + load_config.return_value = {"device": SUBSCRIPTION_1} + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client() + + resp = await client.post( + "/api/notify.html5/callback", + json={"type": event_type, "tag": "12345", "target": "device"}, + headers={AUTHORIZATION: "Bearer JWT"}, + ) + + assert resp.status == HTTPStatus.OK + body = await resp.json() + assert body == {"event": event_type, "status": "ok"} + + assert not issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_event_bus_html5_notification.{event_type}", + ) From 972058311cc11287a604ced3f178f0db3cb81764 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 9 Jul 2026 06:39:16 +1000 Subject: [PATCH 295/707] Fix Teslemetry covers reporting a false closed/open state when data is missing (#175748) --- homeassistant/components/teslemetry/cover.py | 9 +- tests/components/teslemetry/const.py | 4 + .../teslemetry/snapshots/test_cover.ambr | 265 ++++++++++++++++++ tests/components/teslemetry/test_cover.py | 23 +- 4 files changed, 297 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 154ae3b47794..c718f3db8ffb 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -283,7 +283,8 @@ class TeslemetryVehiclePollingChargePortEntity( @override def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = not self._value + value = self._value + self._attr_is_closed = None if value is None else not value class TeslemetryStreamingChargePortEntity( @@ -356,7 +357,8 @@ class TeslemetryVehiclePollingFrontTrunkEntity( @override def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + value = self._value + self._attr_is_closed = None if value is None else value == CLOSED class TeslemetryStreamingFrontTrunkEntity( @@ -430,7 +432,8 @@ class TeslemetryVehiclePollingRearTrunkEntity( @override def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + value = self._value + self._attr_is_closed = None if value is None else value == CLOSED class TeslemetryStreamingRearTrunkEntity( diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index de385735b61a..88c28648dc43 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -17,6 +17,10 @@ VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ASLEEP = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ASLEEP["response"]["state"] = TeslemetryState.OFFLINE VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) +VEHICLE_DATA_NONE = load_json_object_fixture("vehicle_data.json", DOMAIN) +VEHICLE_DATA_NONE["response"]["vehicle_state"]["ft"] = None +VEHICLE_DATA_NONE["response"]["vehicle_state"]["rt"] = None +VEHICLE_DATA_NONE["response"]["charge_state"]["charge_port_door_open"] = None LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) SITE_INFO_WEEK_CROSSING = load_json_object_fixture( diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index f00138333468..dc00633792f6 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -476,6 +476,271 @@ 'state': 'open', }) # --- +# name: test_cover_none[cover.test_charge_port_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge port door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_charge_port_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Charge port door', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_none[cover.test_frunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frunk', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Frunk', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_none[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sunroof', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Test Sunroof', + : False, + : , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_none[cover.test_trunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trunk', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Trunk', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_none[cover.test_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Windows', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Test Windows', + : True, + : , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_cover_noscope[cover.test_charge_port_door-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 7184e488a03a..4e45ab2c6dd8 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -20,7 +20,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import assert_entities, setup_platform -from .const import COMMAND_ERRORS, COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT +from .const import ( + COMMAND_ERRORS, + COMMAND_OK, + METADATA_NOSCOPE, + VEHICLE_DATA_ALT, + VEHICLE_DATA_NONE, +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -51,6 +57,21 @@ async def test_cover_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_cover_none( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that polling covers report unknown when coordinator data is null.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_NONE + entry = await setup_platform(hass, [Platform.COVER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_noscope( hass: HomeAssistant, From db484a0571a5a459bcbf2552b3ad2fa25d5e6c70 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:40:27 +0200 Subject: [PATCH 296/707] Migrate input_select entity attributes to StrEnum (#175757) Co-authored-by: Franck Nijhof Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/input_select/__init__.py | 12 ++++++++---- homeassistant/components/input_select/const.py | 9 +++++++++ .../components/input_select/reproduce_state.py | 7 +++++-- 3 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/input_select/const.py diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 7d7c809c68e5..ac220c1a24b6 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -15,8 +15,9 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, SERVICE_SELECT_PREVIOUS, SelectEntity, + SelectEntityCapabilityAttribute, ) -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, CONF_ICON, CONF_ID, @@ -33,6 +34,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputSelectEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "input_select" @@ -250,9 +253,10 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" _entity_component_unrecorded_attributes = ( - SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} # noqa: SLF001 + SelectEntity._entity_component_unrecorded_attributes # noqa: SLF001 + - {SelectEntityCapabilityAttribute.OPTIONS} ) - _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _unrecorded_attributes = frozenset({InputSelectEntityStateAttribute.EDITABLE}) _attr_should_poll = False editable: bool @@ -299,7 +303,7 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): @override def extra_state_attributes(self) -> dict[str, bool]: """Return the state attributes.""" - return {ATTR_EDITABLE: self.editable} + return {InputSelectEntityStateAttribute.EDITABLE: self.editable} @override async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/input_select/const.py b/homeassistant/components/input_select/const.py new file mode 100644 index 000000000000..46e2e69d3371 --- /dev/null +++ b/homeassistant/components/input_select/const.py @@ -0,0 +1,9 @@ +"""Constants for the input_select integration.""" + +from enum import StrEnum + + +class InputSelectEntityStateAttribute(StrEnum): + """State attributes for input select entities.""" + + EDITABLE = "editable" diff --git a/homeassistant/components/input_select/reproduce_state.py b/homeassistant/components/input_select/reproduce_state.py index f3781d70cd4f..a02dab5ea15b 100644 --- a/homeassistant/components/input_select/reproduce_state.py +++ b/homeassistant/components/input_select/reproduce_state.py @@ -5,6 +5,7 @@ from collections.abc import Iterable, Mapping import logging from typing import Any +from homeassistant.components.select import SelectEntityCapabilityAttribute from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION from homeassistant.core import Context, HomeAssistant, State @@ -39,9 +40,11 @@ async def _async_reproduce_state( service_data = {ATTR_ENTITY_ID: state.entity_id} # If options are specified, call SERVICE_SET_OPTIONS - if ATTR_OPTIONS in state.attributes: + if SelectEntityCapabilityAttribute.OPTIONS in state.attributes: service = SERVICE_SET_OPTIONS - service_data[ATTR_OPTIONS] = state.attributes[ATTR_OPTIONS] + service_data[ATTR_OPTIONS] = state.attributes[ + SelectEntityCapabilityAttribute.OPTIONS + ] await hass.services.async_call( DOMAIN, service, service_data, context=context, blocking=True From fe7346052bf5b09b9eddaf6b696c64d321ec2e0a Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 9 Jul 2026 05:40:55 +0900 Subject: [PATCH 297/707] Add new 'auto' fan mode to LG ThinQ's climate. (#174580) Co-authored-by: YunseonPark-LGE Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/lg_thinq/climate.py | 10 +- .../components/lg_thinq/strings.json | 6 +- tests/components/lg_thinq/conftest.py | 2 + .../fixtures/air_conditioner/profile.json | 4 +- .../fixtures/air_conditioner1/device.json | 9 + .../air_conditioner1/energy_profile.json | 4 + .../fixtures/air_conditioner1/profile.json | 128 ++++++++++++ .../fixtures/air_conditioner1/status.json | 29 +++ .../fixtures/air_conditioner2/device.json | 9 + .../air_conditioner2/energy_profile.json | 4 + .../fixtures/air_conditioner2/profile.json | 164 +++++++++++++++ .../fixtures/air_conditioner2/status.json | 30 +++ .../lg_thinq/snapshots/test_climate.ambr | 188 +++++++++++++++++- tests/components/lg_thinq/test_climate.py | 59 +++++- 14 files changed, 637 insertions(+), 9 deletions(-) create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner1/device.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner1/energy_profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner1/profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner1/status.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner2/device.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner2/energy_profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner2/profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner2/status.json diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index b26364695429..b0254a11e27b 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + FAN_AUTO, FAN_MEDIUM, PRESET_NONE, SWING_OFF, @@ -66,6 +67,7 @@ SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()} STR_TO_HA_FAN: dict[str, str] = { "mid": FAN_MEDIUM, + "nature": FAN_AUTO, } HA_FAN_TO_STR = {v: k for k, v in STR_TO_HA_FAN.items()} @@ -276,16 +278,20 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" + # Handle both reported fan mode variants ("auto" and "nature") safely. + thinq_fan_mode = HA_FAN_TO_STR.get(fan_mode, fan_mode) + if thinq_fan_mode not in self.data.fan_modes: + thinq_fan_mode = fan_mode _LOGGER.debug( "[%s:%s] async_set_fan_mode: %s", self.coordinator.device_name, self.property_id, - fan_mode, + thinq_fan_mode, ) await self.async_call_api( self.coordinator.api.async_set_fan_mode( self.property_id, - HA_FAN_TO_STR.get(fan_mode, fan_mode), + thinq_fan_mode, ) ) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index abd8d4d50d07..4ded58f514fd 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -72,11 +72,15 @@ "fan_mode": { "state": { "auto": "[%key:common::state::auto%]", + "breeze": "Light breeze", "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", + "low_mid": "Low medium", "medium": "[%key:common::state::medium%]", + "mid_high": "Medium high", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", - "slow": "Slow" + "slow": "Slow", + "slow_low": "Super low" } }, "preset_mode": { diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index fb8faf7d0697..c373c5dac5da 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -115,6 +115,8 @@ def mock_thinq_mqtt_client() -> Generator[None]: @pytest.fixture( params=[ "air_conditioner", + "air_conditioner1", + "air_conditioner2", "washer", "dehumidifier", "kimchi_refrigerator", diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json index 85ce95da0edd..a4c1ccac3ac3 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -18,8 +18,8 @@ "mode": ["r", "w"], "type": "enum", "value": { - "r": ["LOW", "HIGH", "MID"], - "w": ["LOW", "HIGH", "MID"] + "r": ["LOW", "MID", "HIGH", "AUTO"], + "w": ["LOW", "MID", "HIGH", "AUTO"] } } }, diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/device.json b/tests/components/lg_thinq/fixtures/air_conditioner1/device.json new file mode 100644 index 000000000000..54d8200a3bc8 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-5D62FF95-A977-46C7-B364-9A160C2A63A0", + "deviceInfo": { + "deviceType": "DEVICE_AIR_CONDITIONER", + "modelName": "RAC_056905_WW", + "alias": "Test air conditioner1", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/energy_profile.json b/tests/components/lg_thinq/fixtures/air_conditioner1/energy_profile.json new file mode 100644 index 000000000000..05dd9622432b --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/energy_profile.json @@ -0,0 +1,4 @@ +{ + "resultCode": "0000", + "result": {} +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner1/profile.json new file mode 100644 index 000000000000..f579b34c39f0 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/profile.json @@ -0,0 +1,128 @@ +{ + "property": { + "airConJobMode": { + "currentJobMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["FAN", "COOL", "AIR_DRY"], + "w": ["FAN", "COOL", "AIR_DRY"] + } + } + }, + "airFlow": { + "windStrength": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["SLOW", "LOW", "MID", "HIGH", "AUTO"], + "w": ["SLOW", "LOW", "MID", "HIGH", "AUTO"] + } + }, + "windStrengthDetail": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["BREEZE", "NATURE", "LOW", "MID", "HIGH"], + "w": ["BREEZE", "NATURE", "LOW", "MID", "HIGH"] + } + } + }, + "operation": { + "airConOperationMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["POWER_ON", "POWER_OFF"], + "w": ["POWER_ON", "POWER_OFF"] + } + } + }, + "temperature": { + "coolTargetTemperature": { + "mode": ["w"], + "type": "range", + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "currentTemperature": { + "mode": ["r"], + "type": "number" + }, + "targetTemperature": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 30, + "min": 18, + "step": 1 + }, + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": { + "mode": ["r"], + "type": "enum", + "value": { + "r": ["C", "F"] + } + } + }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/status.json b/tests/components/lg_thinq/fixtures/air_conditioner1/status.json new file mode 100644 index 000000000000..8fdfe8d9d3c2 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/status.json @@ -0,0 +1,29 @@ +{ + "airConJobMode": { + "currentJobMode": "COOL" + }, + "airFlow": { + "windStrength": "AUTO", + "windStrengthDetail": "NATURE" + }, + "operation": { + "airConOperationMode": "POWER_ON" + }, + "temperature": { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ] +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/device.json b/tests/components/lg_thinq/fixtures/air_conditioner2/device.json new file mode 100644 index 000000000000..dee1a530f5b5 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-5E747ABC-6542-4DEF-AA89-4EC4353B2E89", + "deviceInfo": { + "deviceType": "DEVICE_AIR_CONDITIONER", + "modelName": "RAC_056905_WW", + "alias": "Test air conditioner2", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/energy_profile.json b/tests/components/lg_thinq/fixtures/air_conditioner2/energy_profile.json new file mode 100644 index 000000000000..05dd9622432b --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/energy_profile.json @@ -0,0 +1,4 @@ +{ + "resultCode": "0000", + "result": {} +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner2/profile.json new file mode 100644 index 000000000000..6fa876ba66a8 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/profile.json @@ -0,0 +1,164 @@ +{ + "property": { + "airConJobMode": { + "currentJobMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["FAN", "COOL", "AIR_DRY"], + "w": ["FAN", "COOL", "AIR_DRY"] + } + } + }, + "airFlow": { + "windStep": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 7, + "min": 1, + "step": 1 + }, + "w": { + "max": 7, + "min": 1, + "step": 1 + } + } + }, + "windStrength": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["SLOW", "LOW", "MID", "HIGH", "AUTO"], + "w": ["SLOW", "LOW", "MID", "HIGH", "AUTO"] + } + }, + "windStrengthDetail": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": [ + "BREEZE", + "NATURE", + "SLOW", + "SLOW_LOW", + "LOW", + "LOW_MID", + "MID", + "MID_HIGH", + "HIGH" + ], + "w": [ + "BREEZE", + "NATURE", + "SLOW", + "SLOW_LOW", + "LOW", + "LOW_MID", + "MID", + "MID_HIGH", + "HIGH" + ] + } + } + }, + "operation": { + "airConOperationMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["POWER_ON", "POWER_OFF"], + "w": ["POWER_ON", "POWER_OFF"] + } + } + }, + "temperature": { + "coolTargetTemperature": { + "mode": ["w"], + "type": "range", + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "currentTemperature": { + "mode": ["r"], + "type": "number" + }, + "targetTemperature": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 30, + "min": 18, + "step": 1 + }, + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": { + "mode": ["r"], + "type": "enum", + "value": { + "r": ["C", "F"] + } + } + }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/status.json b/tests/components/lg_thinq/fixtures/air_conditioner2/status.json new file mode 100644 index 000000000000..6acf84d064ef --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/status.json @@ -0,0 +1,30 @@ +{ + "airConJobMode": { + "currentJobMode": "COOL" + }, + "airFlow": { + "windStep": 6, + "windStrength": "HIGH", + "windStrengthDetail": "MID_HIGH" + }, + "operation": { + "airConOperationMode": "POWER_ON" + }, + "temperature": { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ] +} diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index 3493bf4e597b..24622fe0bdb9 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -1,4 +1,186 @@ # serializer version: 1 +# name: test_climate_entities[air_conditioner1][climate.test_air_conditioner1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'breeze', + 'auto', + 'low', + 'medium', + 'high', + ]), + : list([ + , + , + , + , + ]), + : 86, + : 64, + : 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_air_conditioner1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'MW2-5D62FF95-A977-46C7-B364-9A160C2A63A0_climate_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[air_conditioner1][climate.test_air_conditioner1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 77, + : 'auto', + : list([ + 'breeze', + 'auto', + 'low', + 'medium', + 'high', + ]), + : 'Test air conditioner1', + : list([ + , + , + , + , + ]), + : 86, + : 64, + : , + : 2, + : 66, + }), + 'context': , + 'entity_id': 'climate.test_air_conditioner1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_entities[air_conditioner2][climate.test_air_conditioner2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'breeze', + 'auto', + 'slow', + 'slow_low', + 'low', + 'low_mid', + 'medium', + 'mid_high', + 'high', + ]), + : list([ + , + , + , + , + ]), + : 86, + : 64, + : 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_air_conditioner2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'MW2-5E747ABC-6542-4DEF-AA89-4EC4353B2E89_climate_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[air_conditioner2][climate.test_air_conditioner2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 77, + : 'mid_high', + : list([ + 'breeze', + 'auto', + 'slow', + 'slow_low', + 'low', + 'low_mid', + 'medium', + 'mid_high', + 'high', + ]), + : 'Test air conditioner2', + : list([ + , + , + , + , + ]), + : 86, + : 64, + : , + : 2, + : 66, + }), + 'context': , + 'entity_id': 'climate.test_air_conditioner2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_climate_entities[air_conditioner][climate.test_air_conditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -8,8 +190,9 @@ 'capabilities': dict({ : list([ 'low', - 'high', 'medium', + 'high', + 'auto', ]), : list([ , @@ -70,8 +253,9 @@ : 'medium', : list([ 'low', - 'high', 'medium', + 'high', + 'auto', ]), : 'Test air conditioner', : list([ diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 6b80151805c2..e9bfe2056645 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -5,7 +5,12 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -16,7 +21,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +@pytest.mark.parametrize( + "device_fixture", ["air_conditioner", "air_conditioner1", "air_conditioner2"] +) async def test_climate_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -31,3 +38,51 @@ async def test_climate_entities( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "service", "service_data", "expected_value"), + [ + ( + "air_conditioner", + "climate.test_air_conditioner", + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: "auto"}, + "auto", + ), + ( + "air_conditioner1", + "climate.test_air_conditioner1", + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: "auto"}, + "nature", + ), + ], +) +async def test_fan_mode_service_calls( + hass: HomeAssistant, + devices: AsyncMock, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + entity_id: str, + service_data: dict, + expected_value: str, +) -> None: + """Test fan_mode service calls send the correct fan mode values.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + coordinator = next(iter(mock_config_entry.runtime_data.coordinators.values())) + coordinator.api.async_set_fan_mode = AsyncMock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + + coordinator.api.async_set_fan_mode.assert_awaited_once_with( + "climate_air_conditioner", expected_value + ) From 944233376c822cf2ded45b0b4581b83f796e5065 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 9 Jul 2026 06:41:20 +1000 Subject: [PATCH 298/707] Clean up Evohome's storage tests (#175660) --- tests/components/evohome/test_storage.py | 91 ++++++++++-------------- 1 file changed, 39 insertions(+), 52 deletions(-) diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 792cc7ba15b5..83563014b4d5 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -1,8 +1,9 @@ """The tests for evohome storage load & save.""" from datetime import datetime, timedelta -from typing import Any, Final, NotRequired, TypedDict +from typing import Any, Final, TypedDict +from evohomeasync.auth import SZ_SESSION_ID, SZ_SESSION_ID_EXPIRES from evohomeasync2.auth import ( SZ_ACCESS_TOKEN, SZ_ACCESS_TOKEN_EXPIRES, @@ -11,6 +12,7 @@ from evohomeasync2.auth import ( import pytest from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER +from homeassistant.components.evohome.storage import _TokenStoreT from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -19,26 +21,10 @@ from .conftest import setup_evohome from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME -class _SessionDataT(TypedDict): - session_id: str - session_id_expires: NotRequired[str] # 2024-07-27T23:57:30+01:00 - - -class _TokenStoreT(TypedDict): - username: str - refresh_token: str - access_token: str - access_token_expires: str # 2024-07-27T23:57:30+01:00 - user_data: NotRequired[_SessionDataT] - - class _EmptyStoreT(TypedDict): pass -SZ_USER_DATA: Final = "user_data" - - def dt_pair(dt_dtm: datetime) -> tuple[datetime, str]: """Return a datetime without milliseconds and its string representation.""" dt_str = dt_dtm.isoformat(timespec="seconds") # e.g. 2024-07-28T00:57:29+01:00 @@ -46,12 +32,10 @@ def dt_pair(dt_dtm: datetime) -> tuple[datetime, str]: ACCESS_TOKEN_EXP_DTM, ACCESS_TOKEN_EXP_STR = dt_pair(dt_util.now() + timedelta(hours=1)) - -USERNAME_DIFF: Final = f"not_{USERNAME}" -USERNAME_SAME: Final = USERNAME +_, SESSION_ID_EXP_STR = dt_pair(dt_util.now() + timedelta(minutes=15)) _TEST_STORAGE_BASE: Final[_TokenStoreT] = { - CONF_USERNAME: USERNAME_SAME, + CONF_USERNAME: USERNAME, SZ_REFRESH_TOKEN: REFRESH_TOKEN, SZ_ACCESS_TOKEN: ACCESS_TOKEN, SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR, @@ -59,8 +43,11 @@ _TEST_STORAGE_BASE: Final[_TokenStoreT] = { TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": _TEST_STORAGE_BASE, - "null_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: None}, # type: ignore[dict-item] - "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"session_id": SESSION_ID}}, + "with_session_id": _TEST_STORAGE_BASE + | { + SZ_SESSION_ID: SESSION_ID, + SZ_SESSION_ID_EXPIRES: SESSION_ID_EXP_STR, + }, # pyright: ignore[reportAssignmentType] } TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { @@ -68,7 +55,7 @@ TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { "store_was_reset": {}, } -DOMAIN_STORAGE_BASE: Final = { +DOMAIN_STORAGE_ROOT: Final = { "version": STORAGE_VER, "minor_version": 1, "key": STORAGE_KEY, @@ -86,21 +73,20 @@ async def test_auth_tokens_null( ) -> None: """Test credentials manager when cache is empty.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": TEST_STORAGE_NULL[idx]} async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[CONF_USERNAME] == USERNAME_SAME + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == USERNAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" - assert ( - dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) - > dt_util.now() - ) + + assert (expires := data.get(SZ_ACCESS_TOKEN_EXPIRES)) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) > dt_util.now() @pytest.mark.parametrize("install", ["minimal"]) @@ -114,18 +100,20 @@ async def test_auth_tokens_same( ) -> None: """Test credentials manager when cache contains valid data for this user.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": TEST_STORAGE_DATA[idx]} async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[CONF_USERNAME] == USERNAME_SAME + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == USERNAME assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN assert data[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES]) == ACCESS_TOKEN_EXP_DTM + + assert (expires := data[SZ_ACCESS_TOKEN_EXPIRES]) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) == ACCESS_TOKEN_EXP_DTM @pytest.mark.parametrize("install", ["minimal"]) @@ -139,27 +127,26 @@ async def test_auth_tokens_past( ) -> None: """Test credentials manager when cache contains expired data for this user.""" - _dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) + # Make this access token have expired in the past... + _, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) - # make this access token have expired in the past... test_data = TEST_STORAGE_DATA[idx].copy() # shallow copy is OK here test_data[SZ_ACCESS_TOKEN_EXPIRES] = dt_str - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": test_data} async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[CONF_USERNAME] == USERNAME_SAME + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == USERNAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" - assert ( - dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) - > dt_util.now() - ) + + assert (expires := data[SZ_ACCESS_TOKEN_EXPIRES]) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) > dt_util.now() @pytest.mark.parametrize("install", ["minimal"]) @@ -173,19 +160,19 @@ async def test_auth_tokens_diff( ) -> None: """Test credentials manager when cache contains data for a different user.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} - config["username"] = USERNAME_DIFF + # Make this access token be for a different user... + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": TEST_STORAGE_DATA[idx]} + config[CONF_USERNAME] = f"new_{USERNAME}" async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[CONF_USERNAME] == USERNAME_DIFF + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == f"new_{USERNAME}" assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" - assert ( - dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) - > dt_util.now() - ) + + assert (expires := data[SZ_ACCESS_TOKEN_EXPIRES]) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) > dt_util.now() From 1d0c72e0d9ff80c673f3b483dbd9e653f05e7fd5 Mon Sep 17 00:00:00 2001 From: iluvdata <86066778+iluvdata@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:45:42 -0400 Subject: [PATCH 299/707] Improve in code comments for file_upload (#169768) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- homeassistant/components/file_upload/__init__.py | 6 +++++- tests/components/file_upload/test_init.py | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 7cbd89cd261c..067e87f559f7 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -36,7 +36,11 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Generator[Path]: """Get an uploaded file. - File is removed at the end of the context. + File is removed at the end of the context. Should be run on the executor thread pool. + Create a wrapper function and call that wrapper function using + hass.async_add_executor_job. Running this function directly by scheduling an executor + job will result in loop blocking teardown code not running on the executor but + rather in the loop. """ if DOMAIN not in hass.data: raise ValueError("File does not exist") diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py index 2bf68f00ea0f..49daf1b3b63f 100644 --- a/tests/components/file_upload/test_init.py +++ b/tests/components/file_upload/test_init.py @@ -17,8 +17,8 @@ from tests.components.image_upload import TEST_IMAGE from tests.typing import ClientSessionGenerator -@pytest.fixture -async def uploaded_file_dir( +@pytest.fixture(name="uploaded_file_dir") +async def upload_file_dir( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> Path: """Test uploading and using a file.""" @@ -51,7 +51,6 @@ async def test_using_file(hass: HomeAssistant, uploaded_file_dir) -> None: assert file_path.parent == uploaded_file_dir assert file_path.read_bytes() == TEST_IMAGE.read_bytes() - # Test it's removed assert not uploaded_file_dir.exists() From 0a612932ead3e0787c9d0045aa0d775a5dfb4a81 Mon Sep 17 00:00:00 2001 From: Tom Carpenter Date: Wed, 8 Jul 2026 21:49:32 +0100 Subject: [PATCH 300/707] Fix core config schema "elevation" validation (#175692) --- homeassistant/components/config/core.py | 2 +- homeassistant/components/homeassistant/__init__.py | 2 +- tests/components/config/test_core.py | 2 +- tests/components/homeassistant/test_init.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 43d398fc2994..94accda30fbd 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -52,7 +52,7 @@ class CheckConfigView(HomeAssistantView): "type": "config/core/update", vol.Optional("country"): cv.country, vol.Optional("currency"): cv.currency, - vol.Optional("elevation"): int, + vol.Optional("elevation"): vol.Coerce(int), vol.Optional("external_url"): vol.Any(cv.url_no_path, None), vol.Optional("internal_url"): vol.Any(cv.url_no_path, None), vol.Optional("language"): cv.language, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 54c6454167b0..5f5a9d52aa0c 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -324,7 +324,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: { vol.Required(ATTR_LATITUDE): cv.latitude, vol.Required(ATTR_LONGITUDE): cv.longitude, - vol.Optional(ATTR_ELEVATION): int, + vol.Optional(ATTR_ELEVATION): vol.Coerce(int), } ), ) diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 12dec880c4f5..5b8ea0bbd05b 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -134,7 +134,7 @@ async def test_websocket_core_update(hass: HomeAssistant, client) -> None: "type": "config/core/update", "latitude": 60, "longitude": 50, - "elevation": 25, + "elevation": 25.6, "location_name": "Huis", "unit_system": "imperial", "time_zone": "America/New_York", diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index d166a759a96f..b23939cb4030 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -291,7 +291,7 @@ async def test_setting_location(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_LOCATION, - {"latitude": 30, "longitude": 40, "elevation": 0}, + {"latitude": 30, "longitude": 40, "elevation": 0.6}, blocking=True, ) assert hass.config.latitude == 30 From 492a03a8145f3033f12c1144803f5b7d83417a3c Mon Sep 17 00:00:00 2001 From: Andrei Nevedomskii Date: Thu, 9 Jul 2026 02:49:50 +0600 Subject: [PATCH 301/707] Fix broadlink not updating state (#169942) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/broadlink/updater.py | 5 ++ tests/components/broadlink/test_remote.py | 69 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 859e5f0d292f..ac3a2799f476 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -90,6 +90,11 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]): # noqa: UP046 self.device.api.model, self.device.api.host[0], ) + if not self.coordinator.last_update_success: + # When the previous refresh already failed, the coordinator + # will skip listener notification, so notify explicitly to + # ensure entities flip to unavailable on this transition. + self.coordinator.async_update_listeners() raise UpdateFailed(err) from err if self.available is False: diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index a55bf63f2270..fd61edf8d3dd 100644 --- a/tests/components/broadlink/test_remote.py +++ b/tests/components/broadlink/test_remote.py @@ -3,19 +3,32 @@ from base64 import b64decode from unittest.mock import call +from broadlink.exceptions import BroadlinkException +from freezegun.api import FrozenDateTimeFactory +import pytest + from homeassistant.components.broadlink.const import DOMAIN +from homeassistant.components.broadlink.updater import BroadlinkRMUpdateManager from homeassistant.components.remote import ( DOMAIN as REMOTE_DOMAIN, SERVICE_SEND_COMMAND, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import get_device +from tests.common import async_fire_time_changed + REMOTE_DEVICES = ["Entrance", "Living Room", "Office", "Garage"] IR_PACKET = ( @@ -78,6 +91,60 @@ async def test_remote_send_command( assert mock_setup.api.auth.call_count == 1 +@pytest.mark.parametrize( + ("error", "ticks_to_unavailable"), + [ + # OSError flips availability on the first failure (fast path). + (OSError("connection refused"), 1), + # A generic BroadlinkException keeps the entity available across the + # first three failed cycles and only flips once SCAN_INTERVAL * 3 has + # elapsed since the last successful update. + (BroadlinkException("update failed"), 4), + ], +) +async def test_remote_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + error: Exception, + ticks_to_unavailable: int, +) -> None: + """Test the remote becomes unavailable on disconnect and recovers on reconnect.""" + device = get_device("Garage") + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = er.async_entries_for_device(entity_registry, device_entry.id) + remote = next(entry for entry in entries if entry.domain == Platform.REMOTE) + + assert hass.states.get(remote.entity_id).state == STATE_ON + + mock_setup.api.check_sensors.side_effect = error + + for _ in range(ticks_to_unavailable - 1): + freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(remote.entity_id).state == STATE_ON + + freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(remote.entity_id).state == STATE_UNAVAILABLE + + mock_setup.api.check_sensors.side_effect = None + + freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(remote.entity_id).state == STATE_ON + + async def test_remote_turn_off_turn_on( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 579e77952b3ce4af46903d18889d67944bbde210 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 8 Jul 2026 22:58:06 +0200 Subject: [PATCH 302/707] Simplify DSMR config flow to a single serial port selector (#173638) Co-authored-by: Claude --- homeassistant/components/dsmr/config_flow.py | 99 +++++--------------- homeassistant/components/dsmr/sensor.py | 68 +++++++++----- homeassistant/components/dsmr/strings.json | 23 ++--- tests/components/dsmr/conftest.py | 36 ++----- tests/components/dsmr/test_config_flow.py | 89 +++--------------- tests/components/dsmr/test_sensor.py | 68 +++++++++++++- 6 files changed, 157 insertions(+), 226 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 4bfe4791d247..439307d8dbac 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -5,11 +5,8 @@ from functools import partial from typing import Any, override from dsmr_parser import obis_references as obis_ref -from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader -from dsmr_parser.clients.rfxtrx_protocol import ( - create_rfxtrx_dsmr_reader, - create_rfxtrx_tcp_dsmr_reader, -) +from dsmr_parser.clients.protocol import create_dsmr_reader +from dsmr_parser.clients.rfxtrx_protocol import create_rfxtrx_dsmr_reader from dsmr_parser.objects import DSMRObject import voluptuous as vol @@ -19,7 +16,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE +from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import SerialPortSelector @@ -41,11 +38,8 @@ from .const import ( class DSMRConnection: """Test the connection to DSMR and receive telegram to read serial ids.""" - def __init__( - self, host: str | None, port: int, dsmr_version: str, protocol: str - ) -> None: + def __init__(self, port: str, dsmr_version: str, protocol: str) -> None: """Initialize.""" - self._host = host self._port = port self._dsmr_version = dsmr_version self._protocol = protocol @@ -86,34 +80,20 @@ class DSMRConnection: self._telegram = telegram transport.close() - if self._host is None: - if self._protocol == DSMR_PROTOCOL: - create_reader = create_dsmr_reader - else: - create_reader = create_rfxtrx_dsmr_reader - reader_factory = partial( - create_reader, - self._port, - self._dsmr_version, - update_telegram, - loop=hass.loop, - ) + if self._protocol == DSMR_PROTOCOL: + create_reader = create_dsmr_reader else: - if self._protocol == DSMR_PROTOCOL: - create_reader = create_tcp_dsmr_reader - else: - create_reader = create_rfxtrx_tcp_dsmr_reader - reader_factory = partial( - create_reader, - self._host, - self._port, - self._dsmr_version, - update_telegram, - loop=hass.loop, - ) + create_reader = create_rfxtrx_dsmr_reader + reader_factory = partial( + create_reader, + self._port, + self._dsmr_version, + update_telegram, + loop=hass.loop, + ) try: - transport, protocol = await asyncio.create_task(reader_factory()) + transport, protocol = await reader_factory() except OSError: LOGGER.exception("Error connecting to DSMR") return False @@ -136,7 +116,6 @@ async def _validate_dsmr_connection( ) -> dict[str, str | None]: """Validate the user input allows us to connect.""" conn = DSMRConnection( - data.get(CONF_HOST), data[CONF_PORT], data[CONF_DSMR_VERSION], protocol, @@ -176,48 +155,12 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Step when user initializes a integration.""" - if user_input is not None: - user_selection = user_input[CONF_TYPE] - if user_selection == "Serial": - return await self.async_step_setup_serial() + """Step when user initializes an integration. - return await self.async_step_setup_network() - - list_of_types = ["Serial", "Network"] - - schema = vol.Schema({vol.Required(CONF_TYPE): vol.In(list_of_types)}) - return self.async_show_form(step_id="user", data_schema=schema) - - async def async_step_setup_network( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step when setting up network configuration.""" - errors: dict[str, str] = {} - if user_input is not None: - data = await self.async_validate_dsmr(user_input, errors) - if not errors: - return self.async_create_entry( - title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data - ) - - schema = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT): int, - vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), - } - ) - return self.async_show_form( - step_id="setup_network", - data_schema=schema, - errors=errors, - ) - - async def async_step_setup_serial( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step when setting up serial configuration.""" + A single serial port selector handles both local serial devices and + network connections; a network meter can be reached by entering a URL + such as ``socket://host:port``. + """ errors: dict[str, str] = {} if user_input is not None: data = await self.async_validate_dsmr(user_input, errors) @@ -231,7 +174,7 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): } ) return self.async_show_form( - step_id="setup_serial", + step_id="user", data_schema=schema, errors=errors, ) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 01407013cce7..e4340404a1b4 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -9,8 +9,9 @@ from datetime import timedelta from enum import IntEnum from functools import partial from typing import override +from urllib.parse import urlparse -from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader +from dsmr_parser.clients.protocol import create_dsmr_reader from dsmr_parser.clients.rfxtrx_protocol import ( create_rfxtrx_dsmr_reader, create_rfxtrx_tcp_dsmr_reader, @@ -61,6 +62,7 @@ from .const import ( DOMAIN, DSMR_PROTOCOL, LOGGER, + RFXTRX_DSMR_PROTOCOL, ) EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" @@ -770,34 +772,54 @@ async def async_setup_entry( hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), telegram ) - # Creates an asyncio.Protocol factory for reading DSMR telegrams from - # serial and calls update_entities_telegram to update entities on arrival - protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL) + # Legacy network entries stored host and port separately; combine them into + # the single socket://host:port form newer entries already use, in memory + # only, so the stored entry stays untouched and rolling back to an older + # Home Assistant version keeps working. + port = entry.data[CONF_PORT] if CONF_HOST in entry.data: - if protocol == DSMR_PROTOCOL: - create_reader = create_tcp_dsmr_reader + port = f"socket://{entry.data[CONF_HOST]}:{port}" + + # Creates an asyncio.Protocol factory for reading DSMR telegrams and calls + # update_entities_telegram to update entities on arrival. A port starting + # with "/" is a local serial device, which doesn't need a liveness check; + # anything else is a network connection that can drop silently, so it gets a + # keep-alive watchdog that closes the connection (triggering a reconnect) + # when no telegram arrives in time. + protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL) + if protocol == RFXTRX_DSMR_PROTOCOL: + if port.startswith("/"): + reader_factory = partial( + create_rfxtrx_dsmr_reader, + port, + dsmr_version, + update_entities_telegram, + loop=hass.loop, + ) else: - create_reader = create_rfxtrx_tcp_dsmr_reader - reader_factory = partial( - create_reader, - entry.data[CONF_HOST], - entry.data[CONF_PORT], - dsmr_version, - update_entities_telegram, - loop=hass.loop, - keep_alive_interval=60, - ) + # The RFXtrx serial reader has no keep-alive support, so the network + # host and port are fed to the dedicated TCP reader instead. + address = urlparse(port) + reader_factory = partial( + create_rfxtrx_tcp_dsmr_reader, + address.hostname, + address.port, + dsmr_version, + update_entities_telegram, + loop=hass.loop, + keep_alive_interval=60, + ) else: - if protocol == DSMR_PROTOCOL: - create_reader = create_dsmr_reader - else: - create_reader = create_rfxtrx_dsmr_reader + # create_dsmr_reader opens both local devices and any URL (socket://, + # esphome://, ...); the only difference is the keep-alive watchdog. + keep_alive = {} if port.startswith("/") else {"keep_alive_interval": 60} reader_factory = partial( - create_reader, - entry.data[CONF_PORT], + create_dsmr_reader, + port, dsmr_version, update_entities_telegram, loop=hass.loop, + **keep_alive, ) async def connect_and_reconnect() -> None: @@ -814,7 +836,7 @@ async def async_setup_entry( update_entities_telegram({}) try: - transport, protocol = await hass.loop.create_task(reader_factory()) + transport, protocol = await reader_factory() if transport: # Register listener to close transport on HA shutdown diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 519c23cc5694..f8fe86fa4368 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -11,26 +11,15 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { - "setup_network": { - "data": { - "dsmr_version": "Select DSMR version", - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" - }, - "title": "Select connection address" - }, - "setup_serial": { - "data": { - "dsmr_version": "[%key:component::dsmr::config::step::setup_network::data::dsmr_version%]", - "port": "Select device" - }, - "title": "[%key:common::config_flow::data::device%]" - }, "user": { "data": { - "type": "Connection type" + "dsmr_version": "Select DSMR version", + "port": "Select device" }, - "title": "Select connection type" + "data_description": { + "port": "Select a serial device, or enter a network address such as socket://host:port" + }, + "title": "[%key:common::config_flow::data::device%]" } } }, diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 62fdaa25a289..6629f18032c8 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -34,15 +34,9 @@ def dsmr_connection_fixture() -> Generator[tuple[MagicMock, MagicMock, MagicMock connection_factory = MagicMock(wraps=connection_factory) - with ( - patch( - "homeassistant.components.dsmr.sensor.create_dsmr_reader", - connection_factory, - ), - patch( - "homeassistant.components.dsmr.sensor.create_tcp_dsmr_reader", - connection_factory, - ), + with patch( + "homeassistant.components.dsmr.sensor.create_dsmr_reader", + connection_factory, ): yield (connection_factory, transport, protocol) closed.set() @@ -156,15 +150,9 @@ def dsmr_connection_send_validate_fixture() -> Generator[ protocol.wait_closed = wait_closed - with ( - patch( - "homeassistant.components.dsmr.config_flow.create_dsmr_reader", - connection_factory, - ), - patch( - "homeassistant.components.dsmr.config_flow.create_tcp_dsmr_reader", - connection_factory, - ), + with patch( + "homeassistant.components.dsmr.config_flow.create_dsmr_reader", + connection_factory, ): yield (connection_factory, transport, protocol) @@ -207,14 +195,8 @@ def rfxtrx_dsmr_connection_send_validate_fixture() -> Generator[ protocol.wait_closed = wait_closed - with ( - patch( - "homeassistant.components.dsmr.config_flow.create_rfxtrx_dsmr_reader", - connection_factory, - ), - patch( - "homeassistant.components.dsmr.config_flow.create_rfxtrx_tcp_dsmr_reader", - connection_factory, - ), + with patch( + "homeassistant.components.dsmr.config_flow.create_rfxtrx_dsmr_reader", + connection_factory, ): yield (connection_factory, transport, protocol) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index eaadb4d4ae63..222e24971c2a 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -32,44 +32,33 @@ async def test_setup_network( hass: HomeAssistant, dsmr_connection_send_validate_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: - """Test we can setup network.""" + """Test we can setup a network connection via a socket URL.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Network"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_network" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", }, ) await hass.async_block_till_done() entry_data = { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", } assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "10.10.0.1:1234" + assert result["title"] == "socket://10.10.0.1:1234" assert result["data"] == {**entry_data, **SERIAL_DATA} @@ -80,7 +69,7 @@ async def test_setup_network_rfxtrx( MagicMock, MagicMock, MagicMock ], ) -> None: - """Test we can setup network.""" + """Test we can setup a network connection via a socket URL for rfxtrx.""" (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture result = await hass.config_entries.flow.async_init( @@ -89,15 +78,6 @@ async def test_setup_network_rfxtrx( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Network"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_network" assert result["errors"] == {} # set-up DSMRProtocol to yield no valid telegram, @@ -108,22 +88,20 @@ async def test_setup_network_rfxtrx( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", }, ) await hass.async_block_till_done() entry_data = { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", } assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "10.10.0.1:1234" + assert result["title"] == "socket://10.10.0.1:1234" assert result["data"] == {**entry_data, **SERIAL_DATA} @@ -207,15 +185,6 @@ async def test_setup_serial( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): @@ -248,15 +217,6 @@ async def test_setup_serial_rfxtrx( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} # set-up DSMRProtocol to yield no valid telegram, @@ -302,15 +262,6 @@ async def test_setup_serial_fail( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch( @@ -323,7 +274,7 @@ async def test_setup_serial_fail( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" + assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} @@ -362,15 +313,6 @@ async def test_setup_serial_timeout( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): @@ -379,7 +321,7 @@ async def test_setup_serial_timeout( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" + assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_communicate"} @@ -406,15 +348,6 @@ async def test_setup_serial_wrong_telegram( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} protocol.telegram = {} @@ -426,7 +359,7 @@ async def test_setup_serial_wrong_telegram( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" + assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_communicate"} diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index c67bdbfd444f..60792ace9f23 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -1380,8 +1380,12 @@ async def test_tcp( await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - assert connection_factory.call_args_list[0][0][0] == "localhost" - assert connection_factory.call_args_list[0][0][1] == "1234" + # Legacy host/port entries are combined into a socket URL in memory and + # opened with the keep-alive watchdog; the stored entry is left untouched so + # a downgrade keeps working. + assert mock_entry.data["host"] == "localhost" + assert connection_factory.call_args_list[0][0][0] == "socket://localhost:1234" + assert connection_factory.call_args_list[0][1]["keep_alive_interval"] == 60 async def test_rfxtrx_tcp( @@ -1409,8 +1413,66 @@ async def test_rfxtrx_tcp( await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + # Legacy host/port entries keep using the TCP reader (with keep-alive); the + # stored entry is left untouched so a downgrade keeps working. + assert mock_entry.data["host"] == "localhost" assert connection_factory.call_args_list[0][0][0] == "localhost" - assert connection_factory.call_args_list[0][0][1] == "1234" + assert connection_factory.call_args_list[0][0][1] == 1234 + assert connection_factory.call_args_list[0][1]["keep_alive_interval"] == 60 + + +async def test_tcp_socket_url( + hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] +) -> None: + """A socket:// port should be opened with the keep-alive watchdog.""" + (connection_factory, _transport, _protocol) = dsmr_connection_fixture + + entry_data = { + "port": "socket://localhost:1234", + "dsmr_version": "2.2", + "protocol": "dsmr_protocol", + "serial_id": "1234", + "serial_id_gas": "5678", + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert connection_factory.call_args_list[0][0][0] == "socket://localhost:1234" + assert connection_factory.call_args_list[0][1]["keep_alive_interval"] == 60 + + +async def test_serial_no_keep_alive( + hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] +) -> None: + """A local serial device should use the plain reader without keep-alive.""" + (connection_factory, _transport, _protocol) = dsmr_connection_fixture + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "2.2", + "protocol": "dsmr_protocol", + "serial_id": "1234", + "serial_id_gas": "5678", + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert connection_factory.call_args_list[0][0][0] == "/dev/ttyUSB0" + assert "keep_alive_interval" not in connection_factory.call_args_list[0][1] @patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) From ca13d76aba6253eb11a65e6cba8e31c5e4ae5680 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:00:15 +0200 Subject: [PATCH 303/707] Default play media username to the calling user in Music Assistant (#175257) --- .../components/music_assistant/helpers.py | 18 +++ .../music_assistant/media_player.py | 32 ++-- .../components/music_assistant/strings.json | 2 +- .../music_assistant/test_media_player.py | 151 +++++++++++++++++- 4 files changed, 187 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index 9ee4117b1e61..12d2fe496885 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -44,3 +44,21 @@ def get_music_assistant_client( if entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError("Entry not loaded") return entry.runtime_data.mass + + +async def async_resolve_mass_username( + hass: HomeAssistant, user_id: str, available_usernames: list[str] +) -> str | None: + """Resolve the Music Assistant username for the Home Assistant user.""" + if (user := await hass.auth.async_get_user(user_id)) is None: + return None + for cred in user.credentials: + if cred.auth_provider_type == "homeassistant": + username: str = cred.data["username"] + break + else: + return None + username = username.strip().lower() + if username in available_usernames: + return username + return None diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index cdf843333004..74d1db426191 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -61,7 +61,7 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity -from .helpers import catch_musicassistant_error +from .helpers import async_resolve_mass_username, catch_musicassistant_error from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -460,22 +460,28 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): username: str | None = None, ) -> None: """Send the play_media command to the media player.""" - # verify username availability - if username is not None: - users = await self.mass.auth.list_users() + # An explicit username is validated strictly; when omitted we default to + # the Home Assistant user that made the call (best-effort, never raises). + user_id = self._context.user_id if self._context is not None else None + if username is not None or user_id is not None: available_usernames = [ user.username - for user in users + for user in await self.mass.auth.list_users() if user.enabled and user.role != UserRole.GUEST ] - if username not in available_usernames: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_username", - translation_placeholders={ - "username": username, - "available_usernames": ", ".join(available_usernames), - }, + if username is not None: + if username not in available_usernames: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_username", + translation_placeholders={ + "username": username, + "available_usernames": ", ".join(available_usernames), + }, + ) + elif user_id is not None: + username = await async_resolve_mass_username( + self.hass, user_id, available_usernames ) media_uris: list[str] = [] diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index dbb65667e6d0..722e272741d3 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -426,7 +426,7 @@ "name": "Enable radio mode" }, "username": { - "description": "Music Assistant username used for the play media request. This affects the playlog entry and will also take provider filters of a media item into account, if they are configured.", + "description": "Music Assistant username used for the play media request. This affects the playlog entry and will also take provider filters of a media item into account, if they are configured. Defaults to the Home Assistant user that made the request, when the username matches a Music Assistant user.", "name": "Username" } }, diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 3b94bd94c983..00225be7b2ff 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -15,9 +15,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths +from homeassistant.auth.models import Credentials from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, @@ -28,6 +31,7 @@ from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, @@ -72,7 +76,7 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -82,7 +86,7 @@ from .common import ( trigger_subscription_callback, ) -from tests.common import AsyncMock +from tests.common import AsyncMock, MockUser MOCK_TRACK = Track( item_id="1", @@ -824,6 +828,149 @@ async def test_media_player_play_media_action( ) +async def _add_ha_user(hass: HomeAssistant, login_username: str | None) -> MockUser: + """Add a Home Assistant user, optionally with a local login username.""" + user = MockUser(is_owner=True).add_to_hass(hass) + if login_username is not None: + await hass.auth.async_link_user( + user, + Credentials( + auth_provider_type="homeassistant", + auth_provider_id=None, + data={"username": login_username}, + is_new=False, + ), + ) + return user + + +@pytest.mark.parametrize( + ("login_username", "expected_username"), + [ + ("user_user", "user_user"), + ("USER_USER", "user_user"), + ("user_admin", "user_admin"), + ("does_not_exist", None), + ("user_disabled", None), + ("party_guest", None), + (None, None), + ], + ids=[ + "match", + "match_case_insensitive", + "match_admin", + "no_match", + "disabled_user", + "guest_user", + "no_login_username", + ], +) +async def test_media_player_play_media_default_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, + login_username: str | None, + expected_username: str | None, +) -> None: + """Test that play media defaults to the username of the calling user.""" + music_assistant_client.server_info.schema_version = 33 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + + user = await _add_ha_user(hass, login_username) + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + username=expected_username, + sort_by=None, + ) + + +async def test_media_player_play_media_default_username_explicit_override( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that an explicit username takes precedence over the calling user.""" + music_assistant_client.server_info.schema_version = 33 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + + user = await _add_ha_user(hass, "user_user") + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_USERNAME: "user_admin", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + username="user_admin", + sort_by=None, + ) + + +async def test_media_player_standard_play_media_default_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that the standard play_media action also defaults to the calling user.""" + music_assistant_client.server_info.schema_version = 33 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + + user = await _add_ha_user(hass, "user_user") + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_CONTENT_ID: "spotify://track/1234", + ATTR_MEDIA_CONTENT_TYPE: "music", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + username="user_user", + sort_by=None, + ) + + async def test_media_player_play_announcement_action( hass: HomeAssistant, music_assistant_client: MagicMock, From 30815a0f4162ff40588aafb7674528d7a0a3c0fc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 8 Jul 2026 23:00:37 +0200 Subject: [PATCH 304/707] Core integrations not explicitly setting config entry parameter for coordinator now raises (#175608) --- homeassistant/helpers/update_coordinator.py | 2 +- tests/helpers/test_update_coordinator.py | 35 ++++++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 78ab8b7accf6..4efe8fd385d3 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -101,8 +101,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): frame.report_usage( "relies on ContextVar, but should pass the config entry explicitly.", core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, custom_integration_behavior=frame.ReportBehavior.IGNORE, - breaks_in_ha_version="2026.8", ) self.config_entry = config_entries.current_entry.get() diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 93ab1de77f68..aedeb1ae6cbd 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1097,25 +1097,32 @@ async def test_config_entry( not in caplog.text ) - # Default without context should log a warning + # Default without context should raise caplog.clear() - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert crd.config_entry is None - assert ( - "Detected that integration 'my_integration' relies on ContextVar, " - "but should pass the config entry explicitly." - ) in caplog.text + crd = None + with pytest.raises( + RuntimeError, + match=( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ), + ): + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd is None - # Default with context should log a warning + # Default with context should raise caplog.clear() frame._REPORTED_INTEGRATIONS.clear() config_entries.current_entry.set(entry) - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert ( - "Detected that integration 'my_integration' relies on ContextVar, " - "but should pass the config entry explicitly." - ) in caplog.text - assert crd.config_entry is entry + with pytest.raises( + RuntimeError, + match=( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ), + ): + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd is None @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) From ee59001f723c28ea62e34f820f6674d5f5da9bec Mon Sep 17 00:00:00 2001 From: Mattheinrichs Date: Wed, 8 Jul 2026 16:05:28 -0500 Subject: [PATCH 305/707] Add navigation destination text entity to Tessie integration (#169155) --- homeassistant/components/tessie/__init__.py | 1 + homeassistant/components/tessie/icons.json | 5 ++ homeassistant/components/tessie/strings.json | 5 ++ homeassistant/components/tessie/text.py | 42 +++++++++++ .../tessie/snapshots/test_text.ambr | 60 +++++++++++++++ tests/components/tessie/test_text.py | 75 +++++++++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 homeassistant/components/tessie/text.py create mode 100644 tests/components/tessie/snapshots/test_text.ambr create mode 100644 tests/components/tessie/test_text.py diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 7237255375fa..30042482cb1b 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -50,6 +50,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TEXT, Platform.UPDATE, ] diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json index b90af3ddff03..4ba74f3eb330 100644 --- a/homeassistant/components/tessie/icons.json +++ b/homeassistant/components/tessie/icons.json @@ -291,6 +291,11 @@ "vehicle_state_valet_mode": { "default": "mdi:bow-tie" } + }, + "text": { + "navigation_destination": { + "default": "mdi:map-marker" + } } } } diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 9be7124a85c8..36cce2ef326c 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -612,6 +612,11 @@ "name": "Valet mode" } }, + "text": { + "navigation_destination": { + "name": "Navigation destination" + } + }, "update": { "update": { "name": "[%key:component::update::title%]" diff --git a/homeassistant/components/tessie/text.py b/homeassistant/components/tessie/text.py new file mode 100644 index 000000000000..0168c579a48b --- /dev/null +++ b/homeassistant/components/tessie/text.py @@ -0,0 +1,42 @@ +"""Text platform for Tessie integration.""" + +from typing import override + +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TessieConfigEntry +from .entity import TessieEntity +from .models import TessieVehicleData + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tessie Text platform from a config entry.""" + async_add_entities( + TessieNavigationTextEntity(vehicle) for vehicle in entry.runtime_data.vehicles + ) + + +class TessieNavigationTextEntity(TessieEntity, TextEntity): + """Text entity to send a navigation destination to the vehicle.""" + + _attr_mode = TextMode.TEXT + _attr_native_max = 255 + _attr_native_min = 1 + _attr_native_value: str | None = None + + def __init__(self, vehicle: TessieVehicleData) -> None: + """Initialize the navigation text entity.""" + super().__init__(vehicle, "navigation_destination") + + @override + async def async_set_value(self, value: str) -> None: + """Send a navigation destination to the vehicle.""" + await self.run(self.api.navigation_request(value)) diff --git a/tests/components/tessie/snapshots/test_text.ambr b/tests/components/tessie/snapshots/test_text.ambr new file mode 100644 index 000000000000..4064adbbbf86 --- /dev/null +++ b/tests/components/tessie/snapshots/test_text.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_text_entities[text.test_navigation_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 1, + : , + : None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': None, + 'entity_id': 'text.test_navigation_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Navigation destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Navigation destination', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'navigation_destination', + 'unique_id': 'VINVINVIN-navigation_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_text_entities[text.test_navigation_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Navigation destination', + : 255, + : 1, + : , + : None, + }), + 'context': , + 'entity_id': 'text.test_navigation_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tessie/test_text.py b/tests/components/tessie/test_text.py new file mode 100644 index 000000000000..e19ae0e32be6 --- /dev/null +++ b/tests/components/tessie/test_text.py @@ -0,0 +1,75 @@ +"""Test the Tessie text platform.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .common import ERROR_UNKNOWN, assert_entities, setup_platform + +NAVIGATION_ENTITY_ID = "text.test_navigation_destination" + + +async def test_text_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the navigation text entity is set up correctly.""" + entry = await setup_platform(hass, [Platform.TEXT]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_set_navigation_destination(hass: HomeAssistant) -> None: + """Test sending a navigation destination to the vehicle.""" + await setup_platform(hass, [Platform.TEXT]) + + with patch( + "tesla_fleet_api.tessie.Vehicle.navigation_request", + ) as mock_nav: + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: NAVIGATION_ENTITY_ID, + ATTR_VALUE: "1 Infinite Loop, Cupertino, CA", + }, + blocking=True, + ) + mock_nav.assert_called_once_with("1 Infinite Loop, Cupertino, CA") + + +async def test_set_navigation_destination_error(hass: HomeAssistant) -> None: + """Test that a transport error is translated to HomeAssistantError.""" + await setup_platform(hass, [Platform.TEXT]) + + with ( + patch( + "tesla_fleet_api.tessie.Vehicle.navigation_request", + side_effect=ERROR_UNKNOWN, + ) as mock_nav, + pytest.raises(HomeAssistantError) as error, + ): + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: NAVIGATION_ENTITY_ID, + ATTR_VALUE: "Times Square, New York", + }, + blocking=True, + ) + + mock_nav.assert_called_once() + assert error.value.translation_domain == "tessie" + assert error.value.translation_key == "cannot_connect" From ded6b3513f3c24c4fb36ff263ec5159870a44a5e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 8 Jul 2026 23:09:29 +0200 Subject: [PATCH 306/707] Add window and tilt binary sensors for Overkiz ContactSensor devices (#174409) --- .../components/overkiz/binary_sensor.py | 28 +- .../setup/cloud_somfy_tahoma_v2_europe.json | 489 ++++++++++++++++++ .../overkiz/snapshots/test_binary_sensor.ambr | 153 ++++++ .../components/overkiz/test_binary_sensor.py | 42 ++ 4 files changed, 711 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index f614d9555670..957767709dd8 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast, override -from pyoverkiz.enums import OverkizCommandParam, OverkizState +from pyoverkiz.enums import OverkizCommandParam, OverkizState, UIClass, UIWidget from pyoverkiz.types import StateType as OverkizStateType from homeassistant.components.binary_sensor import ( @@ -26,6 +26,10 @@ class OverkizBinarySensorDescription(BinarySensorEntityDescription): value_fn: Callable[[OverkizStateType], bool] + # Restrict this entity to the listed device types (UIWidget/UIClass). + # When omitted, the sensor applies to any device exposing the state. + device_types: list[UIWidget | UIClass] | None = None + BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ # RainSensor/RainSensor @@ -135,6 +139,23 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ ) ), ), + # ContactSensor/IntrusionEventSensor + # (io:SomfyWindowStateSensor, io:SomfySlidingWindowStateSensor) + OverkizBinarySensorDescription( + key=OverkizState.CORE_OPEN_CLOSED, + device_class=BinarySensorDeviceClass.WINDOW, + value_fn=lambda state: state == OverkizCommandParam.OPEN, + # core:OpenClosedState is also exposed by all cover devices, + # restrict this to ContactSensor devices (e.g. the Somfy IntelliTAG) + device_types=[UIClass.CONTACT_SENSOR], + ), + # ContactSensor/IntrusionEventSensor (io:SomfyWindowStateSensor) + OverkizBinarySensorDescription( + key=OverkizState.CORE_TILTED, + name="Tilt", + icon="mdi:angle-acute", + value_fn=bool, + ), ] SUPPORTED_STATES = { @@ -166,6 +187,11 @@ async def async_setup_entry( ) for state in device.definition.states if (description := SUPPORTED_STATES.get(state)) + and ( + description.device_types is None + or device.widget in description.device_types + or device.ui_class in description.device_types + ) ) async_add_entities(entities) diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json index ca8245b58efb..ead1a7538e24 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json @@ -9473,6 +9473,495 @@ "type": 1, "oid": "e01dc448-51ec-4b7e-b538-b8fd0e4a8375", "uiClass": "DoorLock" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "Balcony Window", + "deviceURL": "io://1234-1234-6233/8059108#1", + "shortcut": false, + "controllableName": "io:SomfyWindowStateSensor", + "definition": { + "commands": [ + { + "commandName": "executeManufacturerProcedure", + "nparams": 1 + }, + { + "commandName": "writeManufacturerData", + "nparams": 1 + }, + { + "commandName": "readManufacturerData", + "nparams": 1 + }, + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "runManufacturerSettingsCommand", + "nparams": 2 + } + ], + "type": "SENSOR", + "uiClass": "ContactSensor", + "states": [ + { + "name": "core:BatteryState" + }, + { + "name": "core:SensorDefectState" + }, + { + "name": "core:ErrorsState" + }, + { + "name": "core:StatusState" + }, + { + "name": "core:ManufacturerDiagnosticsState" + }, + { + "name": "core:ManufacturerSettingsState" + }, + { + "name": "core:NameState" + }, + { + "name": "core:TiltedState" + }, + { + "name": "core:OpenClosedState" + }, + { + "name": "core:LockedUnlockedState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:RSSILevelState" + } + ], + "widgetName": "IntrusionEventSensor" + }, + "states": [ + { + "value": "good", + "type": 3, + "name": "core:DiscreteRSSILevelState" + }, + { + "value": -48, + "type": 1, + "name": "core:RSSILevelState" + }, + { + "value": "available", + "type": 3, + "name": "core:StatusState" + }, + { + "value": "unlocked", + "type": 3, + "name": "core:LockedUnlockedState" + }, + { + "value": "IntelliTAG air", + "type": 3, + "name": "core:NameState" + }, + { + "value": false, + "type": 6, + "name": "core:TiltedState" + }, + { + "value": "open", + "type": 3, + "name": "core:OpenClosedState" + } + ], + "attributes": [ + { + "value": [ + "cde_nb", + "customer_production_date", + "eom_name", + "integrated_sensor_position", + "intrusion_detection_level_threshold", + "max_detected_temperature", + "nb_intrusion_detected_per_threshold_of_vibration", + "nb_of_oem_factory_mode_due_to_local_stimuli", + "number_of_battery_door_openings", + "number_of_buttons_pressed", + "number_of_locks", + "number_of_tilt_openings", + "number_of_turn_openings", + "number_of_unknown_positions", + "of_nb", + "production_date", + "window_depth", + "window_exposure", + "window_exterior_appearance", + "window_fitting_range", + "window_glazing_range", + "window_height", + "window_interior_appearance", + "window_model_name", + "window_number_of_panels", + "window_sensed_panel", + "window_type", + "window_type_of_material", + "window_width" + ], + "type": 10, + "name": "core:SupportedReadableManufacturerData" + }, + { + "value": "Somfy", + "type": 3, + "name": "core:Manufacturer" + }, + { + "value": [], + "type": 10, + "name": "core:SupportedManufacturerProcedures" + }, + { + "value": "5151415A15", + "type": 3, + "name": "core:FirmwareRevision" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "IntrusionEventSensor", + "type": 2, + "oid": "intellitag-0", + "uiClass": "ContactSensor" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "Garage Sliding Window", + "deviceURL": "io://1234-1234-6233/10538479#1", + "shortcut": false, + "controllableName": "io:SomfySlidingWindowStateSensor", + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "executeManufacturerProcedure", + "nparams": 1 + }, + { + "commandName": "readManufacturerData", + "nparams": 1 + }, + { + "commandName": "writeManufacturerData", + "nparams": 1 + } + ], + "type": "SENSOR", + "uiClass": "ContactSensor", + "states": [ + { + "name": "core:BatteryState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:ErrorsState" + }, + { + "name": "core:LockedUnlockedState" + }, + { + "name": "core:NameState" + }, + { + "name": "core:OpenClosedState" + }, + { + "name": "core:RSSILevelState" + }, + { + "name": "core:SensorDefectState" + }, + { + "name": "core:StatusState" + } + ], + "widgetName": "IntrusionEventSensor" + }, + "states": [ + { + "name": "core:OpenClosedState", + "type": 3, + "value": "closed" + }, + { + "name": "core:LockedUnlockedState", + "type": 3, + "value": "locked" + }, + { + "name": "core:NameState", + "type": 3, + "value": "IntelliTAG air" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 1, + "value": -55 + } + ], + "attributes": [ + { + "name": "core:SupportedManufacturerProcedures", + "type": 10, + "value": [] + }, + { + "name": "core:SupportedReadableManufacturerData", + "type": 10, + "value": [ + "cde_nb", + "customer_production_date", + "eom_name", + "integrated_sensor_position", + "intrusion_detection_level_threshold", + "max_detected_temperature", + "nb_intrusion_detected_per_threshold_of_vibration", + "nb_of_oem_factory_mode_due_to_local_stimuli", + "number_of_buttons_pressed", + "number_of_locks", + "number_of_sliding_openings", + "number_of_unknown_positions", + "of_nb", + "production_date", + "window_depth", + "window_exposure", + "window_exterior_appearance", + "window_fitting_range", + "window_glazing_range", + "window_height", + "window_interior_appearance", + "window_model_name", + "window_number_of_panels", + "window_sensed_panel", + "window_type", + "window_type_of_material", + "window_width" + ] + }, + { + "name": "core:MaxSensedValue", + "type": 1, + "value": 65535 + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155011A08" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "IntrusionEventSensor", + "type": 2, + "oid": "intellitag-1", + "uiClass": "ContactSensor" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "Garage Sliding Window", + "deviceURL": "io://1234-1234-6233/10538479#2", + "shortcut": false, + "controllableName": "io:SomfyIDEOIIntrusionSensor", + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "executeManufacturerProcedure", + "nparams": 1 + }, + { + "commandName": "readManufacturerData", + "nparams": 1 + }, + { + "commandName": "setVibrationLevelThreshold", + "nparams": 1 + }, + { + "commandName": "writeManufacturerData", + "nparams": 1 + } + ], + "type": "SENSOR", + "uiClass": "ContactSensor", + "states": [ + { + "name": "core:BatteryState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:ErrorsState" + }, + { + "name": "core:IntrusionDetectedState" + }, + { + "name": "core:RSSILevelState" + }, + { + "name": "core:SensorDefectState" + }, + { + "name": "core:StatusState" + }, + { + "name": "core:VibrationLevelThresholdState" + } + ], + "widgetName": "IntrusionSensor" + }, + "states": [ + { + "name": "core:VibrationLevelThresholdState", + "type": 1, + "value": 1 + }, + { + "name": "core:IntrusionDetectedState", + "type": 6, + "value": true, + "lastUpdateTime": 1778583613000 + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 1, + "value": -55 + } + ], + "attributes": [ + { + "name": "core:SupportedManufacturerProcedures", + "type": 10, + "value": [] + }, + { + "name": "core:SupportedReadableManufacturerData", + "type": 10, + "value": [ + "cde_nb", + "customer_production_date", + "eom_name", + "integrated_sensor_position", + "intrusion_detection_level_threshold", + "max_detected_temperature", + "nb_intrusion_detected_per_threshold_of_vibration", + "nb_of_oem_factory_mode_due_to_local_stimuli", + "number_of_buttons_pressed", + "number_of_locks", + "number_of_sliding_openings", + "number_of_unknown_positions", + "of_nb", + "production_date", + "window_depth", + "window_exposure", + "window_exterior_appearance", + "window_fitting_range", + "window_glazing_range", + "window_height", + "window_interior_appearance", + "window_model_name", + "window_number_of_panels", + "window_sensed_panel", + "window_type", + "window_type_of_material", + "window_width" + ] + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:MaxSensedValue", + "type": 1, + "value": 65535 + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155011A08" + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "battery" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "IntrusionSensor", + "type": 2, + "oid": "intellitag-2", + "uiClass": "ContactSensor" } ], "zones": [], diff --git a/tests/components/overkiz/snapshots/test_binary_sensor.ambr b/tests/components/overkiz/snapshots/test_binary_sensor.ambr index 0df8fad4896f..6052a5bb0794 100644 --- a/tests/components/overkiz/snapshots/test_binary_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_binary_sensor.ambr @@ -305,6 +305,159 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_balcony_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/8059108#1-core:OpenClosedState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Balcony Window', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_balcony_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window_tilt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_balcony_window_tilt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tilt', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:angle-acute', + 'original_name': 'Tilt', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/8059108#1-core:TiltedState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window_tilt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Balcony Window Tilt', + : 'mdi:angle-acute', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_balcony_window_tilt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_garage_sliding_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_garage_sliding_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/10538479#1-core:OpenClosedState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_garage_sliding_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Garage Sliding Window', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_garage_sliding_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_hallway_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_binary_sensor.py b/tests/components/overkiz/test_binary_sensor.py index 330e54a04002..6675e6aa5006 100644 --- a/tests/components/overkiz/test_binary_sensor.py +++ b/tests/components/overkiz/test_binary_sensor.py @@ -37,6 +37,12 @@ CONTACT_SENSOR = FixtureDevice( "rtds://1234-1234-6233/394781", "binary_sensor.family_wing_porte_contact", ) +# Somfy IntelliTAG air tilt sensor (io:SomfyWindowStateSensor) +TILT_SENSOR = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "io://1234-1234-6233/8059108#1", + "binary_sensor.living_room_balcony_window_tilt", +) SNAPSHOT_FIXTURES = [ SMOKE_SENSOR, @@ -107,6 +113,42 @@ async def test_binary_sensor_smoke_state_update( assert state.state == STATE_ON +async def test_binary_sensor_tilt_state_update( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event-driven state update for a tilt sensor (clear → tilted).""" + await setup_overkiz_integration(fixture=TILT_SENSOR.fixture) + + state = hass.states.get(TILT_SENSOR.entity_id) + assert state + assert state.state == STATE_OFF + + await async_deliver_events( + hass, + freezer, + mock_client, + [ + device_state_changed_event( + TILT_SENSOR.device_url, + [ + { + "name": OverkizState.CORE_TILTED.value, + "type": 6, + "value": True, + }, + ], + ) + ], + ) + + state = hass.states.get(TILT_SENSOR.entity_id) + assert state + assert state.state == STATE_ON + + async def test_binary_sensor_unavailability( hass: HomeAssistant, setup_overkiz_integration: SetupOverkizIntegration, From 2f16026d0bfcb9609971e98c994fc2a0f9ab1556 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:17:03 +0300 Subject: [PATCH 307/707] Add cover platform to Liebherr (#175921) --- homeassistant/components/liebherr/__init__.py | 1 + homeassistant/components/liebherr/cover.py | 171 ++++++++ .../components/liebherr/strings.json | 20 + tests/components/liebherr/conftest.py | 10 + .../liebherr/snapshots/test_cover.ambr | 54 +++ .../liebherr/snapshots/test_diagnostics.ambr | 7 + tests/components/liebherr/test_cover.py | 399 ++++++++++++++++++ 7 files changed, 662 insertions(+) create mode 100644 homeassistant/components/liebherr/cover.py create mode 100644 tests/components/liebherr/snapshots/test_cover.ambr create mode 100644 tests/components/liebherr/test_cover.py diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 1fa5231a8db3..c2344ed80231 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -24,6 +24,7 @@ from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.COVER, Platform.LIGHT, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/liebherr/cover.py b/homeassistant/components/liebherr/cover.py new file mode 100644 index 000000000000..e6b8addf4c4c --- /dev/null +++ b/homeassistant/components/liebherr/cover.py @@ -0,0 +1,171 @@ +"""Cover platform for Liebherr integration.""" + +from typing import Any, override + +from pyliebherrhomeapi import AutoDoorControl, DoorState, ZonePosition + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import LiebherrConfigEntry, LiebherrCoordinator +from .entity import ZONE_POSITION_MAP, LiebherrEntity + +PARALLEL_UPDATES = 1 + + +def _create_cover_entities( + coordinators: list[LiebherrCoordinator], +) -> list[LiebherrAutoDoor]: + """Create cover entities for the given coordinators.""" + entities: list[LiebherrAutoDoor] = [] + + for coordinator in coordinators: + has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1 + + entities.extend( + LiebherrAutoDoor( + coordinator=coordinator, + zone_id=zone_id, + has_multiple_zones=has_multiple_zones, + ) + for zone_id in coordinator.data.get_auto_door_controls() + ) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LiebherrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Liebherr cover entities.""" + async_add_entities( + _create_cover_entities(list(entry.runtime_data.coordinators.values())) + ) + + @callback + def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None: + """Add cover entities for new devices.""" + async_add_entities(_create_cover_entities(coordinators)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device + ) + ) + + +class LiebherrAutoDoor(LiebherrEntity, CoverEntity): + """Representation of a Liebherr auto door.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + _attr_translation_key = "auto_door" + _optimistic_state: bool | None = None + + def __init__( + self, + coordinator: LiebherrCoordinator, + zone_id: int, + has_multiple_zones: bool, + ) -> None: + """Initialize the auto door entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{coordinator.device_id}_auto_door_{zone_id}" + + # Add zone suffix only for multi-zone devices + if has_multiple_zones: + temp_controls = coordinator.data.get_temperature_controls() + if ( + (tc := temp_controls.get(zone_id)) + and isinstance(tc.zone_position, ZonePosition) + and (zone_key := ZONE_POSITION_MAP.get(tc.zone_position)) + ): + self._attr_translation_key = f"auto_door_{zone_key}" + + @property + def _auto_door_control(self) -> AutoDoorControl | None: + """Get the auto door control for this zone.""" + return self.coordinator.data.get_auto_door_controls().get(self._zone_id) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._auto_door_control is not None + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._optimistic_state = None + super()._handle_coordinator_update() + + @property + @override + def is_closed(self) -> bool | None: + """Return if the door is closed.""" + if self._optimistic_state is not None: + return False + control = self._auto_door_control + if control is None or control.value is None: + return None + return control.value == DoorState.CLOSED + + @property + @override + def is_opening(self) -> bool | None: + """Return if the door is opening.""" + if self._optimistic_state is None: + return False + return self._optimistic_state + + @property + @override + def is_closing(self) -> bool | None: + """Return if the door is closing.""" + if self._optimistic_state is None: + return False + return not self._optimistic_state + + async def _async_set_door(self, value: bool) -> None: + """Open or close the door.""" + self._optimistic_state = value + self.async_write_ha_state() + try: + await self._async_send_command( + self.coordinator.client.trigger_auto_door( + device_id=self.coordinator.device_id, + zone_id=self._zone_id, + value=value, + ) + ) + except HomeAssistantError as err: + self._optimistic_state = None + self.async_write_ha_state() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="open_auto_door_error" + if value + else "close_auto_door_error", + ) from err + + @override + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the door.""" + await self._async_set_door(True) + + @override + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the door.""" + await self._async_set_door(False) diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json index 06d557f1eacf..8bb453c7953e 100644 --- a/homeassistant/components/liebherr/strings.json +++ b/homeassistant/components/liebherr/strings.json @@ -33,6 +33,20 @@ } }, "entity": { + "cover": { + "auto_door": { + "name": "AutoDoor" + }, + "auto_door_bottom_zone": { + "name": "Bottom zone AutoDoor" + }, + "auto_door_middle_zone": { + "name": "Middle zone AutoDoor" + }, + "auto_door_top_zone": { + "name": "Top zone AutoDoor" + } + }, "light": { "presentation_light": { "name": "Presentation light" @@ -203,8 +217,14 @@ } }, "exceptions": { + "close_auto_door_error": { + "message": "An error occurred while closing the door" + }, "communication_error": { "message": "An error occurred while communicating with the device" + }, + "open_auto_door_error": { + "message": "An error occurred while opening the door" } } } diff --git a/tests/components/liebherr/conftest.py b/tests/components/liebherr/conftest.py index f6ca8f45808d..7314f0ef7acc 100644 --- a/tests/components/liebherr/conftest.py +++ b/tests/components/liebherr/conftest.py @@ -6,11 +6,13 @@ from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch from pyliebherrhomeapi import ( + AutoDoorControl, BioFreshPlusControl, BioFreshPlusMode, Device, DeviceState, DeviceType, + DoorState, HydroBreezeControl, HydroBreezeMode, IceMakerControl, @@ -122,6 +124,13 @@ MOCK_DEVICE_STATE = DeviceState( value=3, max=5, ), + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=DoorState.CLOSED, + ), ], ) @@ -183,6 +192,7 @@ def mock_liebherr_client() -> Generator[MagicMock]: client.set_hydro_breeze = AsyncMock() client.set_bio_fresh_plus = AsyncMock() client.set_presentation_light = AsyncMock() + client.trigger_auto_door = AsyncMock() yield client diff --git a/tests/components/liebherr/snapshots/test_cover.ambr b/tests/components/liebherr/snapshots/test_cover.ambr new file mode 100644 index 000000000000..05e71e9cf39c --- /dev/null +++ b/tests/components/liebherr/snapshots/test_cover.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_covers[cover.test_fridge_top_zone_autodoor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_fridge_top_zone_autodoor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Top zone AutoDoor', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Top zone AutoDoor', + 'platform': 'liebherr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'auto_door_top_zone', + 'unique_id': 'test_device_id_auto_door_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_fridge_top_zone_autodoor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Fridge Top zone AutoDoor', + : True, + : , + }), + 'context': , + 'entity_id': 'cover.test_fridge_top_zone_autodoor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/liebherr/snapshots/test_diagnostics.ambr b/tests/components/liebherr/snapshots/test_diagnostics.ambr index d912f096e71b..63161baaec62 100644 --- a/tests/components/liebherr/snapshots/test_diagnostics.ambr +++ b/tests/components/liebherr/snapshots/test_diagnostics.ambr @@ -93,6 +93,13 @@ 'type': 'PresentationLightControl', 'value': 3, }), + dict({ + 'name': 'autodoor', + 'type': 'AutoDoorControl', + 'value': 'closed', + 'zone_id': 1, + 'zone_position': 'top', + }), ]), 'device': dict({ 'device_id': 'test_device_id', diff --git a/tests/components/liebherr/test_cover.py b/tests/components/liebherr/test_cover.py new file mode 100644 index 000000000000..06c2dc151c86 --- /dev/null +++ b/tests/components/liebherr/test_cover.py @@ -0,0 +1,399 @@ +"""Test the Liebherr cover platform.""" + +import copy +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyliebherrhomeapi import ( + AutoDoorControl, + Device, + DeviceState, + DeviceType, + DoorState, + TemperatureControl, + TemperatureUnit, + ZonePosition, +) +from pyliebherrhomeapi.exceptions import LiebherrConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "cover.test_fridge_top_zone_autodoor" + + +def _mock_door_state(door_state: DoorState | None) -> DeviceState: + """Create a DeviceState with a single AutoDoorControl.""" + return DeviceState( + device=MOCK_DEVICE, + controls=[ + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=door_state, + ), + ], + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.COVER] + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("init_integration") +async def test_covers( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test all cover entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("door_state", "expected_state"), + [ + (DoorState.OPEN, STATE_OPEN), + (DoorState.MOVING, STATE_OPEN), + (None, "unknown"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_state_after_poll( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, + door_state: DoorState | None, + expected_state: str, +) -> None: + """Test cover state after polling different door states.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(door_state) + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service", "door_state", "expected_state", "expected_value"), + [ + (SERVICE_OPEN_COVER, DoorState.OPEN, STATE_OPEN, True), + (SERVICE_CLOSE_COVER, DoorState.CLOSED, STATE_CLOSED, False), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_service_calls( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + service: str, + door_state: DoorState, + expected_state: str, + expected_value: bool, +) -> None: + """Test cover open/close service calls settle to expected state.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(door_state) + ) + + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_liebherr_client.trigger_auto_door.assert_called_once_with( + device_id="test_device_id", + zone_id=1, + value=expected_value, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.usefixtures("init_integration") +async def test_cover_state_settles_after_poll( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test door state settles correctly across command and subsequent poll.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(DoorState.OPEN) + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OPEN + + # Door closes on next scheduled poll + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(DoorState.CLOSED) + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_CLOSED + + +@pytest.mark.parametrize( + ("service", "message"), + [ + (SERVICE_OPEN_COVER, "An error occurred while opening the door"), + (SERVICE_CLOSE_COVER, "An error occurred while closing the door"), + ], + ids=["open", "close"], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_failure( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + service: str, + message: str, +) -> None: + """Test cover fails gracefully on connection error and resets optimistic state.""" + mock_liebherr_client.trigger_auto_door.side_effect = LiebherrConnectionError( + "Connection failed" + ) + + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # Optimistic state should be cleared after failure + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_CLOSED + + +@pytest.mark.usefixtures("init_integration") +async def test_cover_when_control_missing( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test cover entity behavior when auto door control is removed.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_CLOSED + + # Device stops reporting auto door control + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( + device=MOCK_DEVICE, controls=[] + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_no_cover_entity_without_control( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> None: + """Test no cover entity created when device has no auto door control.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( + device=MOCK_DEVICE, controls=[] + ) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.liebherr.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) is None + + +async def test_single_zone_cover( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> None: + """Test single zone device uses name without zone suffix.""" + device = Device( + device_id="single_zone_id", + nickname="Single Zone Fridge", + device_type=DeviceType.FRIDGE, + device_name="K2601", + ) + mock_liebherr_client.get_devices.return_value = [device] + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( + device=device, + controls=[ + TemperatureControl( + zone_id=1, + zone_position=ZonePosition.TOP, + name="Fridge", + type="fridge", + value=5, + target=4, + min=2, + max=8, + unit=TemperatureUnit.CELSIUS, + ), + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=DoorState.CLOSED, + ), + ], + ) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.liebherr.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Single zone device should not have zone suffix + entity_id = "cover.single_zone_fridge_autodoor" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_CLOSED + + +async def test_dynamic_device_discovery( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices with auto door are automatically discovered.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.liebherr.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) is not None + assert hass.states.get("cover.new_fridge_autodoor") is None + + new_device = Device( + device_id="new_device_id", + nickname="New Fridge", + device_type=DeviceType.FRIDGE, + device_name="K2601", + ) + new_device_state = DeviceState( + device=new_device, + controls=[ + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=DoorState.CLOSED, + ), + ], + ) + + mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, new_device] + mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: ( + copy.deepcopy( + new_device_state if device_id == "new_device_id" else MOCK_DEVICE_STATE + ) + ) + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("cover.new_fridge_autodoor") + assert state is not None + assert state.state == STATE_CLOSED + + +@pytest.mark.parametrize( + ("service", "expected_state"), + [ + (SERVICE_OPEN_COVER, STATE_OPENING), + (SERVICE_CLOSE_COVER, STATE_CLOSING), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_optimistic_state( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + service: str, + expected_state: str, +) -> None: + """Test optimistic opening/closing state is set before command completes.""" + states: list[str] = [] + + # Capture the state while the API call is in flight to observe optimistic state + async def _observe_trigger(**kwargs: object) -> None: + state = hass.states.get(ENTITY_ID) + assert state is not None + states.append(state.state) + + mock_liebherr_client.trigger_auto_door.side_effect = _observe_trigger + + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert expected_state in states From 5ec3d4d5ef83ca934c27c49ea6b9c9c7f316673a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 8 Jul 2026 23:31:21 +0200 Subject: [PATCH 308/707] Remove deprecated props from entity platform (#175606) --- homeassistant/helpers/entity_platform.py | 72 ------------------------ tests/helpers/test_entity_platform.py | 53 ----------------- 2 files changed, 125 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 733482d58e2f..f4c4366d1b5a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -38,7 +38,6 @@ from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey from . import device_registry as dr, entity_registry as er, service, translation -from .deprecation import deprecated_function from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later from .frame import report_usage @@ -1233,77 +1232,6 @@ class EntityPlatform: """Return the platform name (e.g hue).""" return self.platform_data.platform_name - @property - @deprecated_function( - "platform_data.component_translations", - breaks_in_ha_version="2026.8", - ) - def component_translations(self) -> dict[str, str]: - """Return the component translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.component_translations - - @property - @deprecated_function( - "platform_data.platform_translations", - breaks_in_ha_version="2026.8", - ) - def platform_translations(self) -> dict[str, str]: - """Return the platform translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.platform_translations - - @property - @deprecated_function( - "platform_data.object_id_component_translations", - breaks_in_ha_version="2026.8", - ) - def object_id_component_translations(self) -> dict[str, str]: - """Return the object ID component translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.object_id_component_translations - - @property - @deprecated_function( - "platform_data.object_id_platform_translations", - breaks_in_ha_version="2026.8", - ) - def object_id_platform_translations(self) -> dict[str, str]: - """Return the object ID platform translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.object_id_platform_translations - - @property - @deprecated_function( - "platform_data.default_language_platform_translations", - breaks_in_ha_version="2026.8", - ) - def default_language_platform_translations(self) -> dict[str, str]: - """Return the default language platform translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.default_language_platform_translations - - @deprecated_function( - "platform_data.async_load_translations", - breaks_in_ha_version="2026.8", - ) - async def async_load_translations(self) -> None: - """Load translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return await self.platform_data.async_load_translations() - @overload def _async_derive_object_ids( diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 6cc0de0fc2e2..9cbe26135226 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2837,56 +2837,3 @@ async def test_add_entity_unknown_subentry( "Can't add entities to unknown subentry unknown-subentry " "of config entry super-mock-id" ) in caplog.text - - -@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) -@pytest.mark.usefixtures("mock_integration_frame") -@pytest.mark.parametrize( - "deprecated_attribute", - [ - "component_translations", - "platform_translations", - "object_id_component_translations", - "object_id_platform_translations", - "default_language_platform_translations", - ], -) -async def test_deprecated_attributes( - hass: HomeAssistant, - deprecated_attribute: str, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test setting the device name based on input info.""" - - platform = MockPlatform() - entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) - - assert getattr(entity_platform, deprecated_attribute) is getattr( - entity_platform.platform_data, deprecated_attribute - ) - assert ( - f"The deprecated function {deprecated_attribute} was called from " - "my_integration. It will be removed in HA Core 2026.8. Use platform_data." - f"{deprecated_attribute} instead, please report it to the author of the " - "'my_integration' custom integration" in caplog.text - ) - - -@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_deprecated_async_load_translations( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test setting the device name based on input info.""" - - platform = MockPlatform() - entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) - - await entity_platform.async_load_translations() - assert ( - "The deprecated function async_load_translations was called from " - "my_integration. It will be removed in HA Core 2026.8. Use platform_data." - "async_load_translations instead, please report it to the author of the " - "'my_integration' custom integration" in caplog.text - ) From 6b96371a2256b80c501cb62dd2f7db4094f0fb71 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 9 Jul 2026 07:34:40 +1000 Subject: [PATCH 309/707] Dynamically follow location/DHW renames in evohome reset buttons (#175616) --- homeassistant/components/evohome/button.py | 41 ++++++++------- .../evohome/snapshots/test_button.ambr | 52 +++++++++---------- 2 files changed, 48 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/evohome/button.py b/homeassistant/components/evohome/button.py index 9e27cb74de73..d6453be98fdd 100644 --- a/homeassistant/components/evohome/button.py +++ b/homeassistant/components/evohome/button.py @@ -30,14 +30,14 @@ async def async_setup_platform( coordinator = hass.data[EVOHOME_DATA].coordinator tcs = hass.data[EVOHOME_DATA].tcs - entities: list[EvoResetButtonBase] = [EvoResetSystemButton(coordinator, tcs)] + entities: list[EvoResetButtonBase] = [EvoSystemResetButton(coordinator, tcs)] entities.extend( - [EvoResetZoneButton(coordinator, z) for z in tcs.zones if is_valid_zone(z)] + EvoZoneResetButton(coordinator, z) for z in tcs.zones if is_valid_zone(z) ) if tcs.hotwater: - entities.append(EvoResetDhwButton(coordinator, tcs.hotwater)) + entities.append(EvoDhwResetButton(coordinator, tcs.hotwater)) async_add_entities(entities) @@ -45,6 +45,7 @@ async def async_setup_platform( class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEntity): """Base for Evohome's Button entities.""" + # for _attr_device_class, ButtonDeviceClass.RESET is not available _attr_entity_category = EntityCategory.CONFIG _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone @@ -56,15 +57,18 @@ class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEnti ) -> None: """Initialize an Evohome reset button entity.""" super().__init__(coordinator, context=evo_device.id) + self._evo_device = evo_device + self._attr_unique_id = f"{evo_device.id}_reset" + @override async def async_press(self) -> None: """Reset the Evohome entity to its base operating mode.""" await self.coordinator.call_client_api(self._evo_device.reset()) -class EvoResetSystemButton(EvoResetButtonBase): +class EvoSystemResetButton(EvoResetButtonBase): """Button entity for system reset.""" _evo_device: evo.ControlSystem @@ -77,28 +81,26 @@ class EvoResetSystemButton(EvoResetButtonBase): """Initialize the system reset button.""" super().__init__(coordinator, evo_device) - self._attr_unique_id = f"{evo_device.id}_reset" - self._attr_name = f"Reset {evo_device.location.name}" + @property + @override + def name(self) -> str: + """Return the entity name (follows location renames).""" + return f"Reset {self._evo_device.location.name}" -class EvoResetDhwButton(EvoResetButtonBase): +class EvoDhwResetButton(EvoResetButtonBase): """Button entity for DHW override reset.""" _evo_device: evo.HotWater - def __init__( - self, - coordinator: EvoDataUpdateCoordinator, - evo_device: evo.HotWater, - ) -> None: - """Initialize the DHW reset button.""" - super().__init__(coordinator, evo_device) - - self._attr_unique_id = f"{evo_device.id}_reset" - self._attr_name = f"Reset {evo_device.name}" + @property + @override + def name(self) -> str: + """Return the entity name (follows location renames).""" + return f"Reset {self._evo_device.location.name} DHW" -class EvoResetZoneButton(EvoResetButtonBase): +class EvoZoneResetButton(EvoResetButtonBase): """Button entity for zone override reset.""" _evo_device: evo.Zone @@ -110,10 +112,11 @@ class EvoResetZoneButton(EvoResetButtonBase): ) -> None: """Initialize the zone reset button.""" super().__init__(coordinator, evo_device) + self._attr_unique_id = f"{unique_zone_id(evo_device)}_reset" @property @override def name(self) -> str: - """Return the name, dynamically following any zone rename.""" + """Return the entity name (follows zone renames).""" return f"Reset {self._evo_device.name}" diff --git a/tests/components/evohome/snapshots/test_button.ambr b/tests/components/evohome/snapshots/test_button.ambr index 82cf48fa2418..02ec2f0a3cee 100644 --- a/tests/components/evohome/snapshots/test_button.ambr +++ b/tests/components/evohome/snapshots/test_button.ambr @@ -25,19 +25,6 @@ 'state': 'unknown', }) # --- -# name: test_setup_platform[botched][button.reset_domestic_hot_water-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Reset Domestic Hot Water', - }), - 'context': , - 'entity_id': 'button.reset_domestic_hot_water', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_setup_platform[botched][button.reset_front_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -116,6 +103,19 @@ 'state': 'unknown', }) # --- +# name: test_setup_platform[botched][button.reset_my_home_dhw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Reset My Home DHW', + }), + 'context': , + 'entity_id': 'button.reset_my_home_dhw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_platform[default][button.reset_bathroom_dn-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -142,19 +142,6 @@ 'state': 'unknown', }) # --- -# name: test_setup_platform[default][button.reset_domestic_hot_water-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Reset Domestic Hot Water', - }), - 'context': , - 'entity_id': 'button.reset_domestic_hot_water', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_setup_platform[default][button.reset_front_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -233,6 +220,19 @@ 'state': 'unknown', }) # --- +# name: test_setup_platform[default][button.reset_my_home_dhw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Reset My Home DHW', + }), + 'context': , + 'entity_id': 'button.reset_my_home_dhw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_platform[default][button.reset_spare_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ From 64d8f372a5db6c7f36f74e27fd13c4e0b6a8209e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Jul 2026 23:39:53 +0200 Subject: [PATCH 310/707] Add configuration URL to MELCloud Home (#176028) --- homeassistant/components/melcloud_home/const.py | 5 +++++ homeassistant/components/melcloud_home/entity.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud_home/const.py b/homeassistant/components/melcloud_home/const.py index 6ce91d654e5f..6adbcd76c4ea 100644 --- a/homeassistant/components/melcloud_home/const.py +++ b/homeassistant/components/melcloud_home/const.py @@ -1,3 +1,8 @@ """Constants for the MELCloud Home integration.""" DOMAIN = "melcloud_home" + +WEB_BASE_URL = "https://melcloudhome.com" + +DEVICE_ATA = "ata" +DEVICE_ATW = "atw" diff --git a/homeassistant/components/melcloud_home/entity.py b/homeassistant/components/melcloud_home/entity.py index 7bb2ac4e2c87..da84d8e9abbe 100644 --- a/homeassistant/components/melcloud_home/entity.py +++ b/homeassistant/components/melcloud_home/entity.py @@ -4,11 +4,12 @@ from abc import abstractmethod from typing import override from aiomelcloudhome import ATAUnit, ATWUnit +from yarl import URL from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DEVICE_ATA, DEVICE_ATW, DOMAIN, WEB_BASE_URL from .coordinator import MelCloudHomeCoordinator @@ -21,6 +22,8 @@ class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]): class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity): """Base entity for a MELCloud Home unit.""" + _unit_type_path: str + def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None: """Initialize the entity.""" super().__init__(coordinator) @@ -30,6 +33,9 @@ class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity): identifiers={(DOMAIN, unit.id)}, name=unit.name, manufacturer="Mitsubishi Electric", + configuration_url=URL( + f"{WEB_BASE_URL}/{self._unit_type_path}/{unit.id}/temperature" + ), ) @abstractmethod @@ -51,6 +57,8 @@ class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity): class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]): """Base entity for a MELCloud Home Air-to-Air unit.""" + _unit_type_path = DEVICE_ATA + @override def _units_dict(self) -> dict[str, ATAUnit]: """Return ATA units dict from coordinator.""" @@ -60,6 +68,8 @@ class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]): class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]): """Base entity for a MELCloud Home Air-to-Water unit.""" + _unit_type_path = DEVICE_ATW + @override def _units_dict(self) -> dict[str, ATWUnit]: """Return ATW units dict from coordinator.""" From 5fb7ceec0dc88e56c327a00b74383d46524be3c1 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 8 Jul 2026 23:40:52 +0200 Subject: [PATCH 311/707] Add wildcard pattern upload to onedrive upload (#174203) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/onedrive/services.py | 122 +++++++++++--- .../components/onedrive/strings.json | 5 +- tests/components/onedrive/test_services.py | 152 ++++++++++++++++++ 3 files changed, 257 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py index 1693454f3864..fdd69d2f5fd6 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import asdict +import glob from pathlib import Path, PurePosixPath from typing import cast @@ -48,20 +49,82 @@ DELETE_SERVICE_SCHEMA = vol.Schema( CONTENT_SIZE_LIMIT = 250 * 1024 * 1024 -def _read_file_contents( +def _split_glob_pattern(pattern: str) -> tuple[str, str]: + """Split a glob pattern into its non-magic base directory and remaining pattern.""" + parts = Path(pattern).parts + base_parts: list[str] = [] + for part in parts: + if glob.has_magic(part): + break + base_parts.append(part) + base = str(Path(*base_parts)) if base_parts else "." + relative_pattern = str(Path(*parts[len(base_parts) :])) + return base, relative_pattern + + +def _expand_filenames( hass: HomeAssistant, filenames: list[str] -) -> list[tuple[str, bytes]]: - """Return the mime types and file contents for each file.""" - missing: list[str] = [] +) -> list[tuple[str, str]]: + """Expand wildcard patterns, preserving subfolder structure.""" + expanded: dict[str, str] = {} + no_matches: list[str] = [] for filename in filenames: - if not hass.config.is_allowed_path(filename): + if not glob.has_magic(filename) or Path(filename).is_file(): + expanded.setdefault(filename, Path(filename).name) + continue + base, relative_pattern = _split_glob_pattern(filename) + + if not hass.config.is_allowed_path(base): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="no_access_to_path", - translation_placeholders={"filename": filename}, + translation_placeholders={"filename": base}, ) - if not Path(filename).exists(): - missing.append(filename) + matches = sorted( + match + for match in glob.glob(relative_pattern, root_dir=base, recursive=True) + if (Path(base) / match).is_file() + ) + if not matches: + no_matches.append(filename) + continue + for match in matches: + full_path = str(Path(base) / match) + relative_path = str(PurePosixPath(*Path(match).parts)) + expanded.setdefault(full_path, relative_path) + if no_matches: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_files_match_pattern", + translation_placeholders={ + "patterns": ", ".join(f"`{p}`" for p in no_matches) + }, + ) + return list(expanded.items()) + + +def _destination_parts(relative_path: str) -> tuple[str, str]: + """Split a relative path into its subfolder path and file name.""" + path = PurePosixPath(relative_path) + parent = str(path.parent) + return ("" if parent == "." else parent, path.name) + + +def _read_file_contents( + hass: HomeAssistant, filenames: list[str] +) -> list[tuple[str, bytes]]: + """Return the destination-relative path and file contents for each file.""" + files = _expand_filenames(hass, filenames) + missing: list[str] = [] + for full_path, _ in files: + if not hass.config.is_allowed_path(full_path): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": full_path}, + ) + if not Path(full_path).exists(): + missing.append(full_path) if missing: raise HomeAssistantError( translation_domain=DOMAIN, @@ -71,20 +134,20 @@ def _read_file_contents( }, ) results = [] - for filename in filenames: - filename_path = Path(filename) - file_size = filename_path.stat().st_size + for full_path, relative_path in files: + path = Path(full_path) + file_size = path.stat().st_size if file_size > CONTENT_SIZE_LIMIT: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="file_too_large", translation_placeholders={ - "filename": filename, + "filename": full_path, "size": str(file_size), "limit": str(CONTENT_SIZE_LIMIT), }, ) - results.append((filename_path.name, filename_path.read_bytes())) + results.append((relative_path, path.read_bytes())) return results @@ -121,18 +184,33 @@ def async_setup_services(hass: HomeAssistant) -> None: hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID] ) client = config_entry.runtime_data.client - upload_tasks = [] file_results = await hass.async_add_executor_job( _read_file_contents, hass, call.data[CONF_FILENAME] ) - # make sure the destination folder exists + # make sure the destination folders exist, preserving subfolder structure + folder_ids: dict[str, str] = {} try: - folder_id = (await client.get_approot()).id + base_folder_id = (await client.get_approot()).id for folder in ( cast(str, call.data[CONF_DESTINATION_FOLDER]).strip("/").split("/") ): - folder_id = (await client.create_folder(folder_id, folder)).id + base_folder_id = (await client.create_folder(base_folder_id, folder)).id + folder_ids[""] = base_folder_id + + for relative_path, _ in file_results: + sub_folder, _ = _destination_parts(relative_path) + if sub_folder in folder_ids: + continue + parent_id = base_folder_id + accumulated = "" + for part in PurePosixPath(sub_folder).parts: + accumulated = f"{accumulated}/{part}" if accumulated else part + if accumulated not in folder_ids: + folder_ids[accumulated] = ( + await client.create_folder(parent_id, part) + ).id + parent_id = folder_ids[accumulated] except OneDriveException as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -140,10 +218,12 @@ def async_setup_services(hass: HomeAssistant) -> None: translation_placeholders={"message": str(err)}, ) from err - upload_tasks = [ - client.upload_file(folder_id, file_name, content) - for file_name, content in file_results - ] + upload_tasks = [] + for relative_path, content in file_results: + sub_folder, name = _destination_parts(relative_path) + upload_tasks.append( + client.upload_file(folder_ids[sub_folder], name, content) + ) try: upload_results = await asyncio.gather(*upload_tasks) except OneDriveException as err: diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 5ba210929b00..11befd773654 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -117,6 +117,9 @@ "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" }, + "no_files_match_pattern": { + "message": "No files match the following patterns: {patterns}" + }, "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" }, @@ -179,7 +182,7 @@ "name": "Destination folder" }, "filename": { - "description": "One or more paths to files to upload.", + "description": "One or more paths to files to upload. Supports wildcards, for example `/config/www/*.jpg` to upload all JPG files in a folder, or `/config/www/**/*.jpg` to also include subfolders. Subfolders matched by a wildcard are recreated inside the destination folder. The characters `*`, `?` and `[` are treated as wildcards; to upload a file whose name contains one of these characters literally, make sure the file exists under that exact name.", "example": "{example_image_path}", "name": "Filenames" } diff --git a/tests/components/onedrive/test_services.py b/tests/components/onedrive/test_services.py index 5e40ea90a461..f2f9d15c432d 100644 --- a/tests/components/onedrive/test_services.py +++ b/tests/components/onedrive/test_services.py @@ -2,6 +2,8 @@ from collections.abc import Generator from dataclasses import dataclass +import glob +from pathlib import Path import re from typing import Any, cast from unittest.mock import MagicMock, Mock, patch @@ -32,6 +34,11 @@ TEST_DESTINATION_PATH = "photos/snapshots/image.jpg" DESTINATION_FOLDER = "TestFolder" +def _is_file(path: Path) -> bool: + """Return True for concrete files, False for glob patterns.""" + return not glob.has_magic(str(path)) + + @dataclass class MockUploadFile: """Dataclass used to configure the test with a fake file behavior.""" @@ -98,6 +105,151 @@ async def test_upload_service( assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "metadata_id" +async def test_upload_service_wildcard( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call to upload content using a wildcard pattern.""" + await setup_integration(hass, mock_config_entry) + + matched_files = ["image1.jpg", "image2.jpg"] + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + return_value=matched_files, + ) as mock_glob, + patch( + "homeassistant.components.onedrive.services.Path.is_file", + autospec=True, + side_effect=_is_file, + ), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: "/config/www/*.jpg", + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + + mock_glob.assert_called_once_with("*.jpg", root_dir="/config/www", recursive=True) + assert mock_onedrive_client.upload_file.call_count == len(matched_files) + uploaded_names = [ + call.args[1] for call in mock_onedrive_client.upload_file.call_args_list + ] + assert uploaded_names == ["image1.jpg", "image2.jpg"] + + +async def test_upload_service_wildcard_recursive( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call to upload content using a recursive wildcard pattern.""" + await setup_integration(hass, mock_config_entry) + + matched_files = ["image1.jpg", "sub/image2.jpg"] + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + return_value=matched_files, + ) as mock_glob, + patch( + "homeassistant.components.onedrive.services.Path.is_file", + autospec=True, + side_effect=_is_file, + ), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: "/config/www/**/*.jpg", + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + + mock_glob.assert_called_once_with( + "**/*.jpg", root_dir="/config/www", recursive=True + ) + assert mock_onedrive_client.upload_file.call_count == len(matched_files) + # the "sub" subfolder is created under the destination folder + created_folders = [ + call.args[1] for call in mock_onedrive_client.create_folder.call_args_list + ] + assert "sub" in created_folders + # the nested file keeps its base name and is routed through the subfolder + uploaded_names = [ + call.args[1] for call in mock_onedrive_client.upload_file.call_args_list + ] + assert uploaded_names == ["image1.jpg", "image2.jpg"] + + +async def test_upload_service_wildcard_no_match( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a wildcard pattern that matches no files.""" + await setup_integration(hass, mock_config_entry) + + pattern = "/config/www/*.jpg" + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + return_value=[], + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: pattern, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + assert exc_info.value.translation_key == "no_files_match_pattern" + assert pattern in exc_info.value.translation_placeholders["patterns"] + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)]) +async def test_upload_service_wildcard_base_not_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a wildcard base directory outside the allowlist is rejected.""" + await setup_integration(hass, mock_config_entry) + + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + ) as mock_glob, + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: "/config/www/*.jpg", + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + assert exc_info.value.translation_key == "no_access_to_path" + assert exc_info.value.translation_placeholders["filename"] == "/config/www" + # globbing must not run for a base directory outside the allowlist + mock_glob.assert_not_called() + + async def test_upload_service_no_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 64fc462ab6fca24cfe1bbc3559def1810c735525 Mon Sep 17 00:00:00 2001 From: Justin Mutter Date: Wed, 8 Jul 2026 17:42:42 -0400 Subject: [PATCH 312/707] Migrate AquaLogic integration to use ConfigFlow (#173448) --- .../components/aqualogic/__init__.py | 132 +++++++++--- .../components/aqualogic/config_flow.py | 109 ++++++++++ homeassistant/components/aqualogic/const.py | 8 + .../components/aqualogic/manifest.json | 3 +- homeassistant/components/aqualogic/sensor.py | 52 ++--- .../components/aqualogic/strings.json | 30 +++ homeassistant/components/aqualogic/switch.py | 74 +++---- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/aqualogic/conftest.py | 110 ++++++---- .../components/aqualogic/test_config_flow.py | 191 ++++++++++++++++++ tests/components/aqualogic/test_init.py | 174 ++++++++++------ tests/components/aqualogic/test_sensor.py | 52 +++-- tests/components/aqualogic/test_switch.py | 72 +++++-- 14 files changed, 756 insertions(+), 254 deletions(-) create mode 100644 homeassistant/components/aqualogic/config_flow.py create mode 100644 homeassistant/components/aqualogic/const.py create mode 100644 homeassistant/components/aqualogic/strings.json create mode 100644 tests/components/aqualogic/test_config_flow.py diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py index 1c0233ecfb65..016dd3b4ca54 100644 --- a/homeassistant/components/aqualogic/__init__.py +++ b/homeassistant/components/aqualogic/__init__.py @@ -1,5 +1,6 @@ """Support for AquaLogic devices.""" +import contextlib from datetime import timedelta import logging import threading @@ -9,22 +10,19 @@ from typing import override from aqualogic.core import AquaLogic import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN, PLATFORMS, UPDATE_TOPIC + _LOGGER = logging.getLogger(__name__) -DOMAIN = "aqualogic" -UPDATE_TOPIC = f"{DOMAIN}_update" -CONF_UNIT = "unit" RECONNECT_INTERVAL = timedelta(seconds=10) CONFIG_SCHEMA = vol.Schema( @@ -36,19 +34,84 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type AquaLogicConfigEntry = ConfigEntry[AquaLogicProcessor] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up AquaLogic platform.""" - host = config[DOMAIN][CONF_HOST] - port = config[DOMAIN][CONF_PORT] - processor = AquaLogicProcessor(hass, host, port) - hass.data[DOMAIN] = processor - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, processor.start_listen) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, processor.shutdown) - _LOGGER.debug("AquaLogicProcessor %s:%i initialized", host, port) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the AquaLogic component.""" + if DOMAIN not in config: + return True + + hass.async_create_task(_async_import(hass, config[DOMAIN])) return True +async def _async_import(hass: HomeAssistant, conf: dict) -> None: + """Import AquaLogic configuration from YAML and surface appropriate issues.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: conf[CONF_HOST], CONF_PORT: conf[CONF_PORT]}, + ) + + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AquaLogic", + }, + ) + return + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AquaLogic", + }, + ) + + +async def async_setup_entry(hass: HomeAssistant, entry: AquaLogicConfigEntry) -> bool: + """Set up AquaLogic from a config entry.""" + processor = AquaLogicProcessor(hass, entry.data[CONF_HOST], entry.data[CONF_PORT]) + entry.runtime_data = processor + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + processor.start() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AquaLogicConfigEntry) -> bool: + """Unload an AquaLogic config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + processor = entry.runtime_data + processor.shutdown() + await hass.async_add_executor_job(lambda: processor.join(timeout=5)) + if processor.is_alive(): + _LOGGER.warning("Processor thread did not stop within timeout") + return unload_ok + + class AquaLogicProcessor(threading.Thread): """AquaLogic event processor thread.""" @@ -59,17 +122,15 @@ class AquaLogicProcessor(threading.Thread): self._host = host self._port = port self._shutdown = False - self._panel = None + self._panel: AquaLogic | None = None - def start_listen(self, event: Event) -> None: - """Start event-processing thread.""" - _LOGGER.debug("Event processing thread started") - self.start() - - def shutdown(self, event: Event) -> None: + def shutdown(self) -> None: """Signal shutdown of processing event.""" _LOGGER.debug("Event processing signaled exit") self._shutdown = True + if (panel := self._panel) is not None and panel._socket is not None: # noqa: SLF001 + with contextlib.suppress(OSError): + panel._socket.close() # noqa: SLF001 def data_changed(self, panel: AquaLogic) -> None: """Aqualogic data changed callback.""" @@ -82,13 +143,26 @@ class AquaLogicProcessor(threading.Thread): while True: panel = AquaLogic() self._panel = panel - panel.connect(self._host, self._port) - panel.process(self.data_changed) + try: + panel.connect(self._host, self._port) + panel.process(self.data_changed) + except OSError: + pass + except Exception as err: + _LOGGER.exception( + "Unexpected error in AquaLogic processor: %s", + type(err).__name__, + ) if self._shutdown: return - _LOGGER.error("Connection to %s:%d lost", self._host, self._port) + _LOGGER.warning( + "Connection to %s:%d lost, retrying in %d seconds", + self._host, + self._port, + int(RECONNECT_INTERVAL.total_seconds()), + ) time.sleep(RECONNECT_INTERVAL.total_seconds()) @property diff --git a/homeassistant/components/aqualogic/config_flow.py b/homeassistant/components/aqualogic/config_flow.py new file mode 100644 index 000000000000..9e7b3f893e0d --- /dev/null +++ b/homeassistant/components/aqualogic/config_flow.py @@ -0,0 +1,109 @@ +"""Config flow for AquaLogic.""" + +import contextlib +import threading +from typing import Any, override + +from aqualogic.core import AquaLogic +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): cv.port, + } +) + +# Worst case scenario, this covers both a plain socket timeout (READ_TIMEOUT) +# and an additional frame-scan timeout (another READ_TIMEOUT), plus one second. +_PROBE_TIMEOUT = AquaLogic.READ_TIMEOUT * 2 + 1 + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class InvalidDevice(Exception): + """Error to indicate the device is not an AquaLogic panel.""" + + +def _verify_device(host: str, port: int) -> None: + """Connect and verify the device is an AquaLogic panel. + + Raises CannotConnect if the host is unreachable. + Raises InvalidDevice if no valid AquaLogic data is received within the timeout. + """ + confirmed = threading.Event() + + def _on_data(_: AquaLogic) -> None: + confirmed.set() + + panel = AquaLogic() + try: + panel.connect(host, port) + except OSError as err: + raise CannotConnect from err + + probe = threading.Thread(target=panel.process, args=(_on_data,), daemon=True) + probe.start() + try: + confirmed.wait(timeout=_PROBE_TIMEOUT) + finally: + if (sock := panel._socket) is not None: # noqa: SLF001 + with contextlib.suppress(OSError): + sock.close() + + if not confirmed.is_set(): + raise InvalidDevice + + +class AquaLogicConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for AquaLogic.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match(user_input) + + try: + await self.hass.async_add_executor_job( + _verify_device, user_input[CONF_HOST], user_input[CONF_PORT] + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidDevice: + errors["base"] = "invalid_device" + else: + return self.async_create_entry(title="AquaLogic", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import AquaLogic config from configuration.yaml.""" + self._async_abort_entries_match( + {CONF_HOST: import_data[CONF_HOST], CONF_PORT: import_data[CONF_PORT]} + ) + + try: + await self.hass.async_add_executor_job( + _verify_device, import_data[CONF_HOST], import_data[CONF_PORT] + ) + except CannotConnect, InvalidDevice: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="AquaLogic", data=import_data) diff --git a/homeassistant/components/aqualogic/const.py b/homeassistant/components/aqualogic/const.py new file mode 100644 index 000000000000..881dfe960dfb --- /dev/null +++ b/homeassistant/components/aqualogic/const.py @@ -0,0 +1,8 @@ +"""Constants for the AquaLogic integration.""" + +from homeassistant.const import Platform + +DOMAIN = "aqualogic" +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] + +UPDATE_TOPIC = f"{DOMAIN}_update" diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json index cc807e4bb198..2165a1f27ef1 100644 --- a/homeassistant/components/aqualogic/manifest.json +++ b/homeassistant/components/aqualogic/manifest.json @@ -2,9 +2,10 @@ "domain": "aqualogic", "name": "AquaLogic", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aqualogic", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["aqualogic"], - "quality_scale": "legacy", "requirements": ["aqualogic==2.6"] } diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index a3ff7c1d9d90..6f03b9d2b196 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -3,27 +3,18 @@ from dataclasses import dataclass from typing import override -import voluptuous as vol - from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - PERCENTAGE, - UnitOfPower, - UnitOfTemperature, -) +from homeassistant.const import PERCENTAGE, UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor +from . import AquaLogicConfigEntry, AquaLogicProcessor +from .const import UPDATE_TOPIC @dataclass(frozen=True) @@ -101,34 +92,18 @@ SENSOR_TYPES: tuple[AquaLogicSensorEntityDescription, ...] = ( ), ) -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ) - } -) - - -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: AquaLogicConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the sensor platform.""" - processor: AquaLogicProcessor = hass.data[DOMAIN] - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + """Set up the sensor entities.""" + processor = entry.runtime_data - entities = [ - AquaLogicSensor(processor, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] - - async_add_entities(entities) + async_add_entities( + AquaLogicSensor(processor, description) for description in SENSOR_TYPES + ) class AquaLogicSensor(SensorEntity): @@ -172,4 +147,5 @@ class AquaLogicSensor(SensorEntity): self._attr_native_value = getattr(panel, self.entity_description.key) self.async_write_ha_state() else: - self._attr_native_unit_of_measurement = None + self._attr_native_value = None + self.async_write_ha_state() diff --git a/homeassistant/components/aqualogic/strings.json b/homeassistant/components/aqualogic/strings.json new file mode 100644 index 000000000000..e8a7414483d4 --- /dev/null +++ b/homeassistant/components/aqualogic/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_device": "The device at the given address is not an AquaLogic panel" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your AquaLogic controller", + "port": "TCP port used to connect to the AquaLogic controller" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your YAML configuration, the AquaLogic device could not be reached or did not respond as an AquaLogic panel. Please ensure the device is accessible and restart Home Assistant to retry, or remove the {domain} key from your configuration and set up the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + } + } +} diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index 09a3d0c7501b..4524028e15a5 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -3,55 +3,39 @@ from typing import Any, override from aqualogic.core import States -import voluptuous as vol -from homeassistant.components.switch import ( - PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, - SwitchEntity, -) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor +from . import AquaLogicConfigEntry, AquaLogicProcessor +from .const import UPDATE_TOPIC -SWITCH_TYPES = { - "lights": "Lights", - "filter": "Filter", - "filter_low_speed": "Filter Low Speed", - "aux_1": "Aux 1", - "aux_2": "Aux 2", - "aux_3": "Aux 3", - "aux_4": "Aux 4", - "aux_5": "Aux 5", - "aux_6": "Aux 6", - "aux_7": "Aux 7", +_SWITCH_MAP: dict[str, tuple[str, States]] = { + "lights": ("Lights", States.LIGHTS), + "filter": ("Filter", States.FILTER), + "filter_low_speed": ("Filter Low Speed", States.FILTER_LOW_SPEED), + "aux_1": ("Aux 1", States.AUX_1), + "aux_2": ("Aux 2", States.AUX_2), + "aux_3": ("Aux 3", States.AUX_3), + "aux_4": ("Aux 4", States.AUX_4), + "aux_5": ("Aux 5", States.AUX_5), + "aux_6": ("Aux 6", States.AUX_6), + "aux_7": ("Aux 7", States.AUX_7), } -PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCH_TYPES)): vol.All( - cv.ensure_list, [vol.In(SWITCH_TYPES)] - ) - } -) - -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: AquaLogicConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the switch platform.""" - processor: AquaLogicProcessor = hass.data[DOMAIN] + """Set up the switch entities.""" + processor = entry.runtime_data async_add_entities( - AquaLogicSwitch(processor, switch_type) - for switch_type in config[CONF_MONITORED_CONDITIONS] + AquaLogicSwitch(processor, switch_type) for switch_type in _SWITCH_MAP ) @@ -62,20 +46,10 @@ class AquaLogicSwitch(SwitchEntity): def __init__(self, processor: AquaLogicProcessor, switch_type: str) -> None: """Initialize switch.""" + name, state = _SWITCH_MAP[switch_type] self._processor = processor - self._state_name = { - "lights": States.LIGHTS, - "filter": States.FILTER, - "filter_low_speed": States.FILTER_LOW_SPEED, - "aux_1": States.AUX_1, - "aux_2": States.AUX_2, - "aux_3": States.AUX_3, - "aux_4": States.AUX_4, - "aux_5": States.AUX_5, - "aux_6": States.AUX_6, - "aux_7": States.AUX_7, - }[switch_type] - self._attr_name = f"AquaLogic {SWITCH_TYPES[switch_type]}" + self._state_name = state + self._attr_name = f"AquaLogic {name}" @property @override diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2967780a36cd..f3c531ae4f15 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -74,6 +74,7 @@ FLOWS = { "aprilaire", "apsystems", "aquacell", + "aqualogic", "aqvify", "aranet", "arcam_fmj", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 318118aaf05a..5a112184ba97 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -499,7 +499,7 @@ "aqualogic": { "name": "AquaLogic", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "aquostv": { diff --git a/tests/components/aqualogic/conftest.py b/tests/components/aqualogic/conftest.py index 2873f0f2c626..dda1d7c672da 100644 --- a/tests/components/aqualogic/conftest.py +++ b/tests/components/aqualogic/conftest.py @@ -1,14 +1,28 @@ -"""Fixtures for AquaLogic tests.""" +"""Fixtures for the AquaLogic integration tests.""" -from collections.abc import Callable +from collections.abc import AsyncGenerator, Generator +from datetime import timedelta from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.aqualogic import AquaLogicProcessor +from homeassistant.components.aqualogic.const import DOMAIN, UPDATE_TOPIC +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.dispatcher import dispatcher_send + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + entry_id="test_aqualogic_entry", + ) @pytest.fixture @@ -30,47 +44,69 @@ def mock_panel() -> MagicMock: @pytest.fixture -def mock_processor(mock_panel: MagicMock) -> MagicMock: - """Return a mock AquaLogicProcessor registered in hass.data.""" - with patch("homeassistant.components.aqualogic.AquaLogicProcessor") as mock_cls: - processor = MagicMock() +def mock_aqualogic_device() -> Generator[MagicMock]: + """Return a mock AquaLogic device that immediately triggers the data callback.""" + with patch( + "homeassistant.components.aqualogic.config_flow.AquaLogic" + ) as mock_al_class: + + def _fake_process(callback: object) -> None: + callback(mock_al_class.return_value) + + mock_al_class.return_value.process.side_effect = _fake_process + yield mock_al_class + + +@pytest.fixture +def mock_processor(hass: HomeAssistant, mock_panel: MagicMock) -> Generator[MagicMock]: + """Mock the AquaLogic processor thread.""" + with patch( + "homeassistant.components.aqualogic.AquaLogicProcessor" + ) as mock_processor_class: + processor = mock_processor_class.return_value processor.panel = mock_panel - mock_cls.return_value = processor + processor.data_changed.side_effect = lambda _: dispatcher_send( + hass, UPDATE_TOPIC + ) yield processor @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_panel: MagicMock -) -> AquaLogicProcessor: - """Set up the AquaLogic integration and run one pass of run() to register the callback. + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_processor: MagicMock, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the AquaLogic integration for testing.""" + mock_config_entry.add_to_hass(hass) - AquaLogic is mocked so mock_panel becomes processor.panel. _shutdown is set before - run() so it exits after a single iteration, registering the data_changed callback - with panel.process() without starting a real network thread. - """ - with patch("homeassistant.components.aqualogic.AquaLogic") as mock_al: - mock_al.return_value = mock_panel - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) + with patch("homeassistant.components.aqualogic.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - processor: AquaLogicProcessor = hass.data[DOMAIN] - processor._shutdown = True - processor.run() - return processor + + return mock_config_entry @pytest.fixture -def update_callback( - init_integration: AquaLogicProcessor, mock_panel: MagicMock -) -> Callable[[], None]: - """Return a callable that fires a panel data update through the registered callback. +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] - Extracts the data_changed callback from the mock's process() call args so tests - trigger updates through the same path as real panel data arriving. - """ - callback = mock_panel.process.call_args[0][0] - return lambda: callback(mock_panel) + +@pytest.fixture +async def processor_run( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[tuple[AquaLogicProcessor, MagicMock]]: + """Provide a real AquaLogicProcessor for testing run() without starting the thread.""" + mock_config_entry.add_to_hass(hass) + with ( + patch("homeassistant.components.aqualogic.RECONNECT_INTERVAL", timedelta(0)), + patch("homeassistant.components.aqualogic.AquaLogic") as mock_al, + patch("homeassistant.components.aqualogic.PLATFORMS", []), + patch("homeassistant.components.aqualogic.AquaLogicProcessor.start"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry.runtime_data, mock_al diff --git a/tests/components/aqualogic/test_config_flow.py b/tests/components/aqualogic/test_config_flow.py new file mode 100644 index 000000000000..1b75100d4189 --- /dev/null +++ b/tests/components/aqualogic/test_config_flow.py @@ -0,0 +1,191 @@ +"""Tests for the AquaLogic config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.aqualogic.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aqualogic.async_setup_entry", return_value=True + ) as mock: + yield mock + + +@pytest.mark.usefixtures("mock_aqualogic_device") +async def test_user_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form and create an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AquaLogic" + assert result["data"] == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899} + mock_setup_entry.assert_called_once() + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_aqualogic_device: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle cannot connect error and allow retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aqualogic_device.return_value.connect.side_effect = OSError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_aqualogic_device.return_value.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_invalid_device( + hass: HomeAssistant, + mock_aqualogic_device: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle a device that does not speak the AquaLogic protocol.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aqualogic_device.return_value.process.side_effect = None + with patch("homeassistant.components.aqualogic.config_flow._PROBE_TIMEOUT", 0): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_device"} + + def _fake_process(callback: object) -> None: + callback(mock_aqualogic_device.return_value) + + mock_aqualogic_device.return_value.process.side_effect = _fake_process + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort if the host/port is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_aqualogic_device") +async def test_import_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test importing from configuration.yaml creates a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AquaLogic" + assert result["data"] == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899} + mock_setup_entry.assert_called_once() + + +async def test_import_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test we abort the import if we cannot connect.""" + with patch( + "homeassistant.components.aqualogic.config_flow.AquaLogic" + ) as mock_al_class: + mock_al_class.return_value.connect.side_effect = OSError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_flow_invalid_device(hass: HomeAssistant) -> None: + """Test we abort the import if the device does not speak the AquaLogic protocol.""" + with ( + patch("homeassistant.components.aqualogic.config_flow.AquaLogic"), + patch("homeassistant.components.aqualogic.config_flow._PROBE_TIMEOUT", 0), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort the import if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/aqualogic/test_init.py b/tests/components/aqualogic/test_init.py index 14725a70fa82..a4f57a153438 100644 --- a/tests/components/aqualogic/test_init.py +++ b/tests/components/aqualogic/test_init.py @@ -1,91 +1,145 @@ """Tests for the AquaLogic integration setup.""" -from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -from homeassistant.components.aqualogic import DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import HomeAssistant +import pytest + +from homeassistant.components.aqualogic import AquaLogicProcessor +from homeassistant.components.aqualogic.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry -async def test_setup_creates_processor( - hass: HomeAssistant, mock_processor: MagicMock + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_processor: MagicMock, ) -> None: - """Test setup registers the processor in hass.data.""" - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) + """Test loading and unloading the config entry starts and stops the processor.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] is mock_processor + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_processor.start.assert_called_once() - -async def test_processor_starts_on_ha_start( - hass: HomeAssistant, mock_processor: MagicMock -) -> None: - """Test the processor thread starts when Home Assistant starts.""" - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - mock_processor.start_listen.assert_called_once() - - -async def test_processor_shuts_down_on_ha_stop( - hass: HomeAssistant, mock_processor: MagicMock -) -> None: - """Test the processor shuts down when Home Assistant stops.""" - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + mock_processor.is_alive.return_value = False + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED mock_processor.shutdown.assert_called_once() + mock_processor.join.assert_called_once_with(timeout=5) -async def test_processor_run_reconnects(hass: HomeAssistant) -> None: - """Test the processor reconnects after a dropped connection.""" +@pytest.mark.usefixtures("mock_aqualogic_device") +async def test_import_from_yaml( + hass: HomeAssistant, + mock_processor: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing from YAML creates a config entry and a deprecation issue.""" assert await async_setup_component( hass, DOMAIN, {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, ) await hass.async_block_till_done() - processor = hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899} + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue is not None + assert issue.issue_domain == DOMAIN + + +async def test_import_from_yaml_cannot_connect( + hass: HomeAssistant, + mock_processor: MagicMock, + mock_aqualogic_device: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a failed YAML import raises a specific issue instead of the migration notice.""" + mock_aqualogic_device.return_value.connect.side_effect = OSError + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, + ) + await hass.async_block_till_done() + + assert hass.config_entries.async_entries(DOMAIN) == [] + + assert ( + issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + is None + ) + + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue is not None + assert issue.issue_domain == DOMAIN + + +async def test_processor_run_exits_on_shutdown( + processor_run: tuple[AquaLogicProcessor, MagicMock], +) -> None: + """Test run() processes once then exits when shutdown is already set.""" + processor, mock_al = processor_run + processor._shutdown = True + processor.run() + mock_al.return_value.connect.assert_called_once_with("1.2.3.4", 8899) + + +async def test_processor_run_continues_after_unexpected_exception( + processor_run: tuple[AquaLogicProcessor, MagicMock], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the processor retries when process() raises an unexpected non-OSError exception.""" + processor, mock_al = processor_run connect_calls = 0 - def stop_on_second_connect(*args: object, **kwargs: object) -> None: + def stop_on_second_connect(*_args: object, **_kwargs: object) -> None: nonlocal connect_calls connect_calls += 1 if connect_calls >= 2: processor._shutdown = True - # Patch RECONNECT_INTERVAL to zero so time.sleep(0) returns immediately - with ( - patch("homeassistant.components.aqualogic.RECONNECT_INTERVAL", timedelta(0)), - patch("homeassistant.components.aqualogic.AquaLogic") as mock_al, - ): - mock_al.return_value.connect.side_effect = stop_on_second_connect - processor.run() + mock_al.return_value.connect.side_effect = stop_on_second_connect + mock_al.return_value.process.side_effect = RuntimeError("unexpected error") + processor.run() + + assert connect_calls == 2 + assert "Unexpected error in AquaLogic processor: RuntimeError" in caplog.text + + +async def test_processor_run_reconnects( + processor_run: tuple[AquaLogicProcessor, MagicMock], +) -> None: + """Test the processor reconnects after a dropped connection.""" + processor, mock_al = processor_run + connect_calls = 0 + + def stop_on_second_connect(*_args: object, **_kwargs: object) -> None: + nonlocal connect_calls + connect_calls += 1 + if connect_calls >= 2: + processor._shutdown = True + + mock_al.return_value.connect.side_effect = stop_on_second_connect + processor.run() assert connect_calls == 2 diff --git a/tests/components/aqualogic/test_sensor.py b/tests/components/aqualogic/test_sensor.py index 7f6981c51b4e..c4eb66853a38 100644 --- a/tests/components/aqualogic/test_sensor.py +++ b/tests/components/aqualogic/test_sensor.py @@ -1,37 +1,28 @@ """Tests for the AquaLogic sensor platform.""" -from collections.abc import Callable from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component @pytest.fixture -async def init_sensors( - hass: HomeAssistant, init_integration: AquaLogicProcessor -) -> None: - """Set up the AquaLogic sensor platform.""" - assert await async_setup_component( - hass, - "sensor", - {"sensor": {"platform": DOMAIN}}, - ) - await hass.async_block_till_done() +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] -@pytest.mark.usefixtures("init_sensors") +@pytest.mark.usefixtures("init_integration") async def test_sensors( hass: HomeAssistant, + mock_processor: MagicMock, snapshot: SnapshotAssertion, - update_callback: Callable[[], None], ) -> None: - """Test all sensor entities are created and report correct state.""" - update_callback() + """Test sensor entities are created and report correct state.""" + mock_processor.data_changed(mock_processor.panel) await hass.async_block_till_done() states = { @@ -41,16 +32,33 @@ async def test_sensors( assert states == snapshot -@pytest.mark.usefixtures("init_sensors") +@pytest.mark.usefixtures("init_integration") async def test_sensors_imperial_units( hass: HomeAssistant, - update_callback: Callable[[], None], - mock_panel: MagicMock, + mock_processor: MagicMock, ) -> None: """Test sensors report imperial units when the panel is not metric.""" - mock_panel.is_metric = False - update_callback() + mock_processor.panel.is_metric = False + mock_processor.data_changed(mock_processor.panel) await hass.async_block_till_done() + # Use salt_level (g/L → PPM) to verify imperial branch without HA unit conversion state = hass.states.get("sensor.aqualogic_salt_level") assert state.attributes["unit_of_measurement"] == "PPM" + + +@pytest.mark.usefixtures("init_integration") +async def test_sensors_no_panel( + hass: HomeAssistant, + mock_processor: MagicMock, +) -> None: + """Test sensors revert to unknown when the panel becomes unavailable.""" + mock_processor.data_changed(mock_processor.panel) + await hass.async_block_till_done() + assert hass.states.get("sensor.aqualogic_air_temperature").state == "25.5" + + mock_processor.panel = None + mock_processor.data_changed(None) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aqualogic_air_temperature").state == STATE_UNKNOWN diff --git a/tests/components/aqualogic/test_switch.py b/tests/components/aqualogic/test_switch.py index a36ad59199f4..ed1bd7d53d54 100644 --- a/tests/components/aqualogic/test_switch.py +++ b/tests/components/aqualogic/test_switch.py @@ -6,32 +6,34 @@ from aqualogic.core import States import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor +from homeassistant.components.aqualogic.const import UPDATE_TOPIC from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.dispatcher import async_dispatcher_send @pytest.fixture -async def init_switches( - hass: HomeAssistant, init_integration: AquaLogicProcessor -) -> None: - """Set up the AquaLogic switch platform.""" - assert await async_setup_component( - hass, - "switch", - {"switch": {"platform": DOMAIN}}, - ) - await hass.async_block_till_done() +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] -@pytest.mark.usefixtures("init_switches") +@pytest.mark.usefixtures("init_integration") async def test_switches( hass: HomeAssistant, snapshot: SnapshotAssertion, ) -> None: - """Test all switch entities are created and report correct state.""" + """Test switch entities are created and report correct state.""" + async_dispatcher_send(hass, UPDATE_TOPIC) + await hass.async_block_till_done() + states = { state.entity_id: state for state in sorted(hass.states.async_all("switch"), key=lambda s: s.entity_id) @@ -39,7 +41,7 @@ async def test_switches( assert states == snapshot -@pytest.mark.usefixtures("init_switches") +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( ("service", "expected_state"), [ @@ -61,3 +63,41 @@ async def test_turn( blocking=True, ) mock_panel.set_state.assert_called_once_with(States.LIGHTS, expected_state) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + pytest.param(SERVICE_TURN_ON, id="turn_on"), + pytest.param(SERVICE_TURN_OFF, id="turn_off"), + ], +) +async def test_turn_no_panel( + hass: HomeAssistant, + mock_processor: MagicMock, + service: str, +) -> None: + """Test that turning a switch does nothing when the panel is unavailable.""" + panel = mock_processor.panel + mock_processor.panel = None + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.aqualogic_lights"}, + blocking=True, + ) + panel.set_state.assert_not_called() + + +@pytest.mark.usefixtures("init_integration") +async def test_is_on_no_panel( + hass: HomeAssistant, + mock_processor: MagicMock, +) -> None: + """Test switch reports off when panel is unavailable.""" + mock_processor.panel = None + async_dispatcher_send(hass, UPDATE_TOPIC) + await hass.async_block_till_done() + + assert hass.states.get("switch.aqualogic_lights").state == STATE_OFF From 4ead445cc76079fb7cf74f0c72750a35e053116e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 9 Jul 2026 05:33:40 +0200 Subject: [PATCH 313/707] Fix bosch_alarm base entity calls (#175992) --- homeassistant/components/bosch_alarm/entity.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py index e5b25e37e290..33a6516792a6 100644 --- a/homeassistant/components/bosch_alarm/entity.py +++ b/homeassistant/components/bosch_alarm/entity.py @@ -48,7 +48,7 @@ class BoschAlarmEntity(Entity): """Stop observing state changes.""" self.panel.connection_status_observer.detach(self.schedule_update_ha_state) if self._observe_faults: - self.panel.faults_observer.attach(self.schedule_update_ha_state) + self.panel.faults_observer.detach(self.schedule_update_ha_state) class BoschAlarmAreaEntity(BoschAlarmEntity): @@ -92,7 +92,7 @@ class BoschAlarmAreaEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() if self._observe_alarms: self._area.alarm_observer.detach(self.schedule_update_ha_state) if self._observe_ready: @@ -126,7 +126,7 @@ class BoschAlarmPointEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() self._point.status_observer.detach(self.schedule_update_ha_state) @@ -155,7 +155,7 @@ class BoschAlarmDoorEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() self._door.status_observer.detach(self.schedule_update_ha_state) @@ -184,5 +184,5 @@ class BoschAlarmOutputEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() self._output.status_observer.detach(self.schedule_update_ha_state) From e8f529278baac80bd90c9e7b25b6f25c1e111e55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Jul 2026 23:22:03 -0600 Subject: [PATCH 314/707] Move the HomeKit thermostat linked fan service into the climate base (#176042) --- .../components/homekit/climate_base.py | 137 +++++++++++++++++- .../components/homekit/type_thermostats.py | 108 +------------- .../homekit/test_type_thermostats.py | 24 +-- 3 files changed, 150 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/homekit/climate_base.py b/homeassistant/components/homekit/climate_base.py index 2d4b13ec3daf..93a3b8b6fa4f 100644 --- a/homeassistant/components/homekit/climate_base.py +++ b/homeassistant/components/homekit/climate_base.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_FAN_MODES, + ATTR_HVAC_ACTION, ATTR_HVAC_MODES, ATTR_MAX_TEMP, ATTR_MIN_TEMP, @@ -22,14 +23,19 @@ from homeassistant.components.climate import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_OFF, + FAN_ON, SERVICE_SET_FAN_MODE, SERVICE_SET_SWING_MODE, SWING_OFF, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES -from homeassistant.core import State +from homeassistant.core import State, callback +from homeassistant.util.percentage import percentage_to_ordered_list_item from .accessories import HomeAccessory from .climate_util import ( @@ -43,11 +49,37 @@ from .climate_util import ( resolve_target_temp_range, temperature_attribute_to_homekit, ) -from .const import CHAR_CURRENT_TEMPERATURE, PROP_MAX_VALUE, PROP_MIN_VALUE +from .const import ( + CHAR_ACTIVE, + CHAR_CURRENT_FAN_STATE, + CHAR_CURRENT_TEMPERATURE, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_FANV2, +) from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) +FAN_STATE_INACTIVE = 0 +FAN_STATE_IDLE = 1 +FAN_STATE_ACTIVE = 2 + +HC_HASS_TO_HOMEKIT_FAN_STATE = { + HVACAction.OFF: FAN_STATE_INACTIVE, + HVACAction.IDLE: FAN_STATE_IDLE, + HVACAction.HEATING: FAN_STATE_ACTIVE, + HVACAction.COOLING: FAN_STATE_ACTIVE, + HVACAction.DRYING: FAN_STATE_ACTIVE, + HVACAction.FAN: FAN_STATE_ACTIVE, + HVACAction.PREHEATING: FAN_STATE_IDLE, + HVACAction.DEFROSTING: FAN_STATE_IDLE, +} + class HomeKitClimateAccessory(HomeAccessory): """Base class for the Thermostat and HeaterCooler accessories.""" @@ -58,6 +90,11 @@ class HomeKitClimateAccessory(HomeAccessory): char_current_temp: Characteristic + # Configured by _configure_fan_service when fan_chars is non-empty. + char_fan_active: Characteristic + char_target_fan_state: Characteristic + char_current_fan_state: Characteristic + def __init__(self, *args: Any) -> None: """Initialize the shared climate accessory state.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) @@ -83,6 +120,10 @@ class HomeKitClimateAccessory(HomeAccessory): self.swing_on_mode = get_swing_on_mode(attributes) self.swing_off_mode = get_swing_off_mode(attributes) + # Characteristics the subclass places on a linked fan service; which + # ones, if any, is the subclass's policy. + self.fan_chars: list[str] = [] + # These attributes drive the characteristic set and valid values, so # reload the accessory when any of them change. self._reload_on_change_attrs.extend( @@ -218,3 +259,95 @@ class HomeKitClimateAccessory(HomeAccessory): swing_mode := attributes.get(ATTR_SWING_MODE) ): self.char_swing.set_value(1 if is_swing_on(swing_mode) else 0) + + def _configure_fan_service(self, primary_serv: Service) -> None: + """Create a linked fan service for the chars in ``fan_chars``.""" + serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars) + primary_serv.add_linked_service(serv_fan) + self.char_fan_active = serv_fan.configure_char( + CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active + ) + if CHAR_SWING_MODE in self.fan_chars: + self.char_swing = serv_fan.configure_char( + CHAR_SWING_MODE, + value=0, + setter_callback=self._set_swing_mode, + ) + self.char_swing.display_name = "Swing Mode" + if CHAR_ROTATION_SPEED in self.fan_chars: + self.char_speed = serv_fan.configure_char( + CHAR_ROTATION_SPEED, + value=100, + properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, + setter_callback=self._set_fan_speed, + ) + self.char_speed.display_name = "Fan Mode" + if CHAR_CURRENT_FAN_STATE in self.fan_chars: + self.char_current_fan_state = serv_fan.configure_char( + CHAR_CURRENT_FAN_STATE, + value=0, + ) + self.char_current_fan_state.display_name = "Fan State" + if CHAR_TARGET_FAN_STATE in self.fan_chars: + self.char_target_fan_state = serv_fan.configure_char( + CHAR_TARGET_FAN_STATE, + value=0, + setter_callback=self._set_fan_auto, + ) + self.char_target_fan_state.display_name = "Fan Auto" + + def _get_on_mode(self) -> str: + """Return the fan mode to use when leaving auto or turning the fan on.""" + if self.ordered_fan_speeds: + speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) + return self.fan_modes[speed_key] + return self.fan_modes[FAN_ON] + + def _set_fan_active(self, active: int) -> None: + """Send the climate fan mode for a HomeKit fan active toggle.""" + _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active) + if FAN_OFF not in self.fan_modes: + _LOGGER.debug( + "%s: Fan does not support off, resetting to on", self.entity_id + ) + self.char_fan_active.value = 1 + self.char_fan_active.notify() + return + mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} + self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) + + def _set_fan_auto(self, auto: int) -> None: + """Send the climate fan mode for a HomeKit fan auto toggle. + + Subclasses must only add CHAR_TARGET_FAN_STATE to ``fan_chars`` when + FAN_AUTO is in ``fan_modes``; this setter assumes the mode exists. + """ + _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) + mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} + self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) + + @callback + def _async_update_fan_service(self, new_state: State) -> None: + """Update the linked fan service from the entity state.""" + attributes = new_state.attributes + + self._update_swing_char(attributes) + self._update_fan_speed_char(attributes) + + fan_mode = attributes.get(ATTR_FAN_MODE) + fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None + if CHAR_TARGET_FAN_STATE in self.fan_chars: + self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0) + + if CHAR_CURRENT_FAN_STATE in self.fan_chars and ( + hvac_action := attributes.get(ATTR_HVAC_ACTION) + ): + self.char_current_fan_state.set_value( + HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action] + ) + + self.char_fan_active.set_value( + int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF) + ) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 8c9e507b590f..1b570c8e4279 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -8,7 +8,6 @@ from pyhap.const import CATEGORY_THERMOSTAT from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, @@ -23,9 +22,7 @@ from homeassistant.components.climate import ( DEFAULT_MIN_HUMIDITY, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, - FAN_OFF, FAN_ON, - SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, @@ -55,7 +52,6 @@ from homeassistant.const import ( ) from homeassistant.core import State, callback from homeassistant.util.enum import try_parse_enum -from homeassistant.util.percentage import percentage_to_ordered_list_item from .accessories import TYPES, HomeAccessory from .climate_base import HomeKitClimateAccessory @@ -64,7 +60,6 @@ from .climate_util import ( temperature_attribute_to_homekit, ) from .const import ( - CHAR_ACTIVE, CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_FAN_STATE, CHAR_CURRENT_HEATING_COOLING, @@ -81,9 +76,7 @@ from .const import ( DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, PROP_MAX_VALUE, - PROP_MIN_STEP, PROP_MIN_VALUE, - SERV_FANV2, SERV_THERMOSTAT, ) from .util import get_min_max, temperature_to_states @@ -145,21 +138,6 @@ HC_HASS_TO_HOMEKIT_ACTION = { HVACAction.DEFROSTING: HC_HEAT_COOL_HEAT, } -FAN_STATE_INACTIVE = 0 -FAN_STATE_IDLE = 1 -FAN_STATE_ACTIVE = 2 - -HC_HASS_TO_HOMEKIT_FAN_STATE = { - HVACAction.OFF: FAN_STATE_INACTIVE, - HVACAction.IDLE: FAN_STATE_IDLE, - HVACAction.HEATING: FAN_STATE_ACTIVE, - HVACAction.COOLING: FAN_STATE_ACTIVE, - HVACAction.DRYING: FAN_STATE_ACTIVE, - HVACAction.FAN: FAN_STATE_ACTIVE, - HVACAction.PREHEATING: FAN_STATE_IDLE, - HVACAction.DEFROSTING: FAN_STATE_IDLE, -} - def _hk_hvac_mode_from_state(state: State) -> int | None: """Return the equivalent HomeKit HVAC mode for a given state.""" @@ -188,7 +166,6 @@ class Thermostat(HomeKitClimateAccessory): # Add additional characteristics if auto mode is supported self.chars: list[str] = [] - self.fan_chars: list[str] = [] attributes = state.attributes min_humidity, _ = get_min_max( @@ -297,69 +274,12 @@ class Thermostat(HomeKitClimateAccessory): if self.fan_chars: if attributes.get(ATTR_HVAC_ACTION) is not None: self.fan_chars.append(CHAR_CURRENT_FAN_STATE) - serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars) - serv_thermostat.add_linked_service(serv_fan) - self.char_active = serv_fan.configure_char( - CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active - ) - if CHAR_SWING_MODE in self.fan_chars: - self.char_swing = serv_fan.configure_char( - CHAR_SWING_MODE, - value=0, - setter_callback=self._set_swing_mode, - ) - self.char_swing.display_name = "Swing Mode" - if CHAR_ROTATION_SPEED in self.fan_chars: - self.char_speed = serv_fan.configure_char( - CHAR_ROTATION_SPEED, - value=100, - properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, - setter_callback=self._set_fan_speed, - ) - self.char_speed.display_name = "Fan Mode" - if CHAR_CURRENT_FAN_STATE in self.fan_chars: - self.char_current_fan_state = serv_fan.configure_char( - CHAR_CURRENT_FAN_STATE, - value=0, - ) - self.char_current_fan_state.display_name = "Fan State" - if CHAR_TARGET_FAN_STATE in self.fan_chars and FAN_AUTO in self.fan_modes: - self.char_target_fan_state = serv_fan.configure_char( - CHAR_TARGET_FAN_STATE, - value=0, - setter_callback=self._set_fan_auto, - ) - self.char_target_fan_state.display_name = "Fan Auto" + self._configure_fan_service(serv_thermostat) self.async_update_state(state) serv_thermostat.setter_callback = self._set_chars - def _get_on_mode(self) -> str: - if self.ordered_fan_speeds: - speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) - return self.fan_modes[speed_key] - return self.fan_modes[FAN_ON] - - def _set_fan_active(self, active: int) -> None: - _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active) - if FAN_OFF not in self.fan_modes: - _LOGGER.debug( - "%s: Fan does not support off, resetting to on", self.entity_id - ) - self.char_active.value = 1 - self.char_active.notify() - return - mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) - - def _set_fan_auto(self, auto: int) -> None: - _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) - mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) - def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] @@ -602,31 +522,7 @@ class Thermostat(HomeKitClimateAccessory): self.char_display_units.set_value(unit) if self.fan_chars: - self._async_update_fan_state(new_state) - - @callback - def _async_update_fan_state(self, new_state: State) -> None: - """Update state without rechecking the device features.""" - attributes = new_state.attributes - - self._update_swing_char(attributes) - self._update_fan_speed_char(attributes) - - fan_mode = attributes.get(ATTR_FAN_MODE) - fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None - if CHAR_TARGET_FAN_STATE in self.fan_chars: - self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0) - - if CHAR_CURRENT_FAN_STATE in self.fan_chars and ( - hvac_action := attributes.get(ATTR_HVAC_ACTION) - ): - self.char_current_fan_state.set_value( - HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action] - ) - - self.char_active.set_value( - int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF) - ) + self._async_update_fan_service(new_state) @TYPES.register("WaterHeater") diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 66fa2c5763da..a311ef1ae21b 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -44,6 +44,11 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.climate_base import ( + FAN_STATE_ACTIVE, + FAN_STATE_IDLE, + FAN_STATE_INACTIVE, +) from homeassistant.components.homekit.const import ( ATTR_VALUE, CHAR_CURRENT_FAN_STATE, @@ -57,9 +62,6 @@ from homeassistant.components.homekit.const import ( PROP_MIN_VALUE, ) from homeassistant.components.homekit.type_thermostats import ( - FAN_STATE_ACTIVE, - FAN_STATE_IDLE, - FAN_STATE_INACTIVE, HC_HEAT_COOL_AUTO, HC_HEAT_COOL_COOL, HC_HEAT_COOL_HEAT, @@ -2336,13 +2338,13 @@ async def test_thermostat_with_fan_modes_with_auto( assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_LOW - char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] + char_fan_active_iid = acc.char_fan_active.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_active_iid, + HAP_REPR_IID: char_fan_active_iid, HAP_REPR_VALUE: 0, } ] @@ -2351,7 +2353,7 @@ async def test_thermostat_with_fan_modes_with_auto( ) await hass.async_block_till_done() - assert acc.char_active.value == 1 + assert acc.char_fan_active.value == 1 char_target_fan_state_iid = acc.char_target_fan_state.to_HAP()[HAP_REPR_IID] @@ -2434,7 +2436,7 @@ async def test_thermostat_with_fan_modes_with_off( assert CHAR_TARGET_FAN_STATE not in acc.fan_chars assert CHAR_SWING_MODE in acc.fan_chars assert CHAR_CURRENT_FAN_STATE in acc.fan_chars - assert acc.char_active.value == 1 + assert acc.char_fan_active.value == 1 hass.states.async_set( entity_id, @@ -2460,16 +2462,16 @@ async def test_thermostat_with_fan_modes_with_off( }, ) await hass.async_block_till_done() - assert acc.char_active.value == 0 + assert acc.char_fan_active.value == 0 call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) - char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] + char_fan_active_iid = acc.char_fan_active.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_active_iid, + HAP_REPR_IID: char_fan_active_iid, HAP_REPR_VALUE: 1, } ] @@ -2487,7 +2489,7 @@ async def test_thermostat_with_fan_modes_with_off( HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_active_iid, + HAP_REPR_IID: char_fan_active_iid, HAP_REPR_VALUE: 0, } ] From f378f9e881696d99aa6c7236e55950baae9763a4 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:13:48 +0300 Subject: [PATCH 315/707] Add translation keys for Liebherr API error handling (#176047) --- homeassistant/components/liebherr/__init__.py | 12 ++++++--- .../components/liebherr/coordinator.py | 27 ++++++++++++------- .../components/liebherr/strings.json | 18 +++++++++++++ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index c2344ed80231..8f596768f197 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -45,11 +45,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> try: devices = await client.get_devices() except LiebherrAuthenticationError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryAuthFailed("Invalid API key") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except LiebherrConnectionError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err # Create a coordinator for each device (may be empty if no devices) data = LiebherrData(client=client) diff --git a/homeassistant/components/liebherr/coordinator.py b/homeassistant/components/liebherr/coordinator.py index 6d9ae1004640..32887b08022a 100644 --- a/homeassistant/components/liebherr/coordinator.py +++ b/homeassistant/components/liebherr/coordinator.py @@ -60,12 +60,15 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]): try: await self.client.get_device(self.device_id) except LiebherrAuthenticationError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryAuthFailed("Invalid API key") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except LiebherrConnectionError as err: - # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady( - f"Failed to connect to device {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="device_connection_error", + translation_placeholders={"device_id": self.device_id}, ) from err @override @@ -74,15 +77,19 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]): try: return await self.client.get_device_state(self.device_id) except LiebherrAuthenticationError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryAuthFailed("API key is no longer valid") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_expired", + ) from err except LiebherrTimeoutError as err: - # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed( - f"Timeout communicating with device {self.device_id}" + translation_domain=DOMAIN, + translation_key="device_timeout_error", + translation_placeholders={"device_id": self.device_id}, ) from err except LiebherrConnectionError as err: - # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed( - f"Error communicating with device {self.device_id}" + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device_id": self.device_id}, ) from err diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json index 8bb453c7953e..ea44defe44e1 100644 --- a/homeassistant/components/liebherr/strings.json +++ b/homeassistant/components/liebherr/strings.json @@ -217,12 +217,30 @@ } }, "exceptions": { + "auth_expired": { + "message": "API key is no longer valid" + }, + "cannot_connect": { + "message": "Failed to connect to the Liebherr API" + }, "close_auto_door_error": { "message": "An error occurred while closing the door" }, "communication_error": { "message": "An error occurred while communicating with the device" }, + "device_communication_error": { + "message": "Error communicating with device {device_id}" + }, + "device_connection_error": { + "message": "Failed to connect to device {device_id}" + }, + "device_timeout_error": { + "message": "Timeout communicating with device {device_id}" + }, + "invalid_api_key": { + "message": "Invalid API key" + }, "open_auto_door_error": { "message": "An error occurred while opening the door" } From aa48ad96b49c08f534f1e827b3eb99361d908648 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Thu, 9 Jul 2026 08:16:13 +0200 Subject: [PATCH 316/707] Fix Rexel OAuth2 session expiring after a day in Overkiz (#176010) --- homeassistant/components/overkiz/application_credentials.py | 2 +- tests/components/overkiz/test_config_flow.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/application_credentials.py b/homeassistant/components/overkiz/application_credentials.py index a3c01abc4a7b..c537edcf1b4c 100644 --- a/homeassistant/components/overkiz/application_credentials.py +++ b/homeassistant/components/overkiz/application_credentials.py @@ -43,6 +43,6 @@ class OverkizOAuth2Implementation(LocalOAuth2ImplementationWithPkce): otherwise drop it. """ return super().extra_authorize_data | { - "scope": REXEL_OAUTH_SCOPE, + "scope": f"{REXEL_OAUTH_SCOPE} offline_access", "p": REXEL_OAUTH_POLICY, } diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 2c4ef7695f5e..a9aa5307e644 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -8,6 +8,7 @@ from pyoverkiz.client import GatewayCandidate from pyoverkiz.const import ( REXEL_OAUTH_AUTHORIZE_URL, REXEL_OAUTH_POLICY, + REXEL_OAUTH_SCOPE, REXEL_OAUTH_TOKEN_URL, ) from pyoverkiz.exceptions import ( @@ -1116,6 +1117,8 @@ async def test_rexel_full_flow_single_gateway( # Azure AD B2C needs the policy on the authorize URL; the helper rebuilds # the query string, so it must survive via extra_authorize_data. assert f"p={REXEL_OAUTH_POLICY}" in result["url"] + # offline_access is required for B2C to return a refresh token. + assert f"{REXEL_OAUTH_SCOPE}+offline_access" in result["url"] await _async_rexel_oauth_external_step( hass, hass_client_no_auth, aioclient_mock, result["flow_id"] From e28427995f073fbe9c90b83e36dac54bf869d91f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 08:21:47 +0200 Subject: [PATCH 317/707] MELCloud Home add reconfigure flow (#176015) --- .../components/melcloud_home/config_flow.py | 34 ++++++ .../melcloud_home/quality_scale.yaml | 2 +- .../components/melcloud_home/strings.json | 12 ++ .../melcloud_home/test_config_flow.py | 106 ++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud_home/config_flow.py b/homeassistant/components/melcloud_home/config_flow.py index 55af4c2e1b48..47edc6276519 100644 --- a/homeassistant/components/melcloud_home/config_flow.py +++ b/homeassistant/components/melcloud_home/config_flow.py @@ -134,3 +134,37 @@ class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + + if user_input is not None: + errors, user_id = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + { + CONF_EMAIL: reconf_entry.data[CONF_EMAIL], + CONF_PASSWORD: reconf_entry.data[CONF_PASSWORD], + }, + ), + errors=errors, + ) diff --git a/homeassistant/components/melcloud_home/quality_scale.yaml b/homeassistant/components/melcloud_home/quality_scale.yaml index c5f7a70aca4c..02c57da54e40 100644 --- a/homeassistant/components/melcloud_home/quality_scale.yaml +++ b/homeassistant/components/melcloud_home/quality_scale.yaml @@ -64,7 +64,7 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: done diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json index a56154c757ff..e424162368d1 100644 --- a/homeassistant/components/melcloud_home/strings.json +++ b/homeassistant/components/melcloud_home/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." }, "error": { @@ -24,6 +25,17 @@ "description": "The credentials for your MELCloud Home account are no longer valid. Enter your current credentials to reauthenticate.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::melcloud_home::config::step::user::data_description::email%]", + "password": "[%key:component::melcloud_home::config::step::user::data_description::password%]" + }, + "description": "Re-enter your credentials to reconfigure your MELCloud Home account." + }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]", diff --git a/tests/components/melcloud_home/test_config_flow.py b/tests/components/melcloud_home/test_config_flow.py index 44fabb494bd8..a0c04b9e04d3 100644 --- a/tests/components/melcloud_home/test_config_flow.py +++ b/tests/components/melcloud_home/test_config_flow.py @@ -209,3 +209,109 @@ async def test_reauth_flow_exceptions( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_config_entry.data == MOCK_REAUTH_INPUT + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_REAUTH_INPUT + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + pytest.param(MelCloudHomeAuthenticationError("bad creds"), "invalid_auth"), + pytest.param(MelCloudHomeConnectionError("offline"), "cannot_connect"), + pytest.param(MelCloudHomeTimeoutError("timed out"), "timeout_connect"), + pytest.param(Exception("unexpected"), "unknown"), + ], +) +async def test_reconfigure_flow_exceptions( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions in the reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_melcloud_client.get_context.side_effect = MelCloudHomeConnectionError( + "offline" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_melcloud_client.get_context.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_REAUTH_INPUT + + +async def test_reconfigure_flow_wrong_account( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reconfigure flow aborts when a different account is used.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_melcloud_client.get_context.return_value = ( + mock_melcloud_client.get_context.return_value.model_copy( + update={"id": "user-uuid-2"} + ) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data == MOCK_USER_INPUT + assert mock_config_entry.unique_id == "user-uuid-1" From fdbee48dd7cea7465b5a51e86fb345696b8413a3 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 08:22:03 +0200 Subject: [PATCH 318/707] MELCloud add test coverage for HVAC modes (#176036) --- .../components/melcloud_home/test_climate.py | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/components/melcloud_home/test_climate.py b/tests/components/melcloud_home/test_climate.py index 4d0f3791166b..f728d6d30473 100644 --- a/tests/components/melcloud_home/test_climate.py +++ b/tests/components/melcloud_home/test_climate.py @@ -1,5 +1,7 @@ """Test the MELCloud Home climate platform.""" +from collections.abc import Callable +from typing import Any from unittest.mock import AsyncMock, patch from aiomelcloudhome import ( @@ -8,6 +10,7 @@ from aiomelcloudhome import ( ATAVaneHorizontal, ATAVaneVertical, ATWZoneMode, + UserContext, ) import pytest @@ -24,6 +27,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.components.melcloud_home.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -36,7 +40,12 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform +from tests.common import ( + MockConfigEntry, + SnapshotAssertion, + async_load_json_object_fixture, + snapshot_platform, +) ATA_ENTITY_ID = "climate.living_room_ac" ATW_ZONE1_ENTITY_ID = "climate.heat_pump_zone_1" @@ -61,6 +70,68 @@ async def test_climate_platform( ) +@pytest.mark.parametrize( + ("apply_capabilities", "expected_hvac_modes"), + [ + pytest.param( + lambda _: None, + [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ], + id="no_capabilities", + ), + pytest.param( + lambda caps: caps, + [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ], + id="all_modes", + ), + pytest.param( + lambda caps: { + **caps, + "hasCoolOperationMode": False, + "hasAutoOperationMode": False, + "hasDryOperationMode": False, + "hasFanOperationMode": False, + }, + [HVACMode.OFF, HVACMode.HEAT], + id="heat_only", + ), + ], +) +async def test_ata_hvac_modes( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + apply_capabilities: Callable[[dict[str, Any]], dict[str, Any] | None], + expected_hvac_modes: list[HVACMode], +) -> None: + """Test ATA hvac_modes for varying unit capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + ata_unit = context["buildings"][0]["airToAirUnits"][0] + ata_unit["capabilities"] = apply_capabilities(ata_unit["capabilities"]) + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["hvac_modes"] == expected_hvac_modes + + @pytest.mark.parametrize( ("hvac_mode", "arguments"), [ From 902159387848e70ff93a0b99a09e3768c21bb1d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 08:43:53 +0200 Subject: [PATCH 319/707] Use config entry noise PSK for ESPHome Z-Wave JS discovery flow (#176057) Co-authored-by: Claude --- .../components/esphome/entry_data.py | 6 ++++-- tests/components/esphome/test_entry_data.py | 10 +++++++++- tests/components/esphome/test_manager.py | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 3003728406dc..8ee0527661a3 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -59,7 +59,7 @@ from homeassistant.helpers import discovery_flow, entity_registry as er from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.storage import Store -from .const import DOMAIN +from .const import CONF_NOISE_PSK, DOMAIN from .dashboard import async_get_dashboard type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData] @@ -517,6 +517,8 @@ class RuntimeEntryData: ) -> None: """Create a zwave_js config flow for a Z-Wave JS Proxy device.""" assert self.client.connected_address is not None + entry = hass.config_entries.async_get_entry(self.entry_id) + noise_psk = entry.data.get(CONF_NOISE_PSK) if entry else None discovery_flow.async_create_flow( hass, "zwave_js", @@ -526,7 +528,7 @@ class RuntimeEntryData: zwave_home_id=zwave_home_id, ip_address=self.client.connected_address, port=self.client.port, - noise_psk=self.client.noise_psk, + noise_psk=noise_psk or None, ), discovery_key=discovery_flow.DiscoveryKey( domain=DOMAIN, diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 4d63c764ffb3..eb7d83057b9b 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( import pytest from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.const import CONF_NOISE_PSK from homeassistant.components.esphome.entry_data import RuntimeEntryData from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -129,6 +130,12 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( async def test_discover_zwave() -> None: """Test ESPHome discovery of Z-Wave JS.""" hass = Mock() + # The noise PSK is read from the config entry, not the live client, so that + # a dynamically provisioned key that could not be applied to the already + # connected client is still passed on to the add-on. + hass.config_entries.async_get_entry.return_value = Mock( + data={CONF_NOISE_PSK: "mock-noise-psk"} + ) entry_data = RuntimeEntryData( "mock-id", "mock-title", @@ -154,6 +161,7 @@ async def test_discover_zwave() -> None: device_info, None, ) + hass.config_entries.async_get_entry.assert_called_once_with("mock-id") mock_create_flow.assert_called_once_with( hass, "zwave_js", @@ -163,7 +171,7 @@ async def test_discover_zwave() -> None: zwave_home_id=1234, ip_address="mock-client-address", port=1234, - noise_psk=None, + noise_psk="mock-noise-psk", ), discovery_key=discovery_flow.DiscoveryKey( domain="esphome", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 343601ffe203..dfde80addd55 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2730,6 +2730,19 @@ async def test_zwave_proxy_request_home_id_change( mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test Z-Wave proxy request handler with HOME_ID_CHANGE request.""" + noise_psk = "cD3vRGhSJTMgc2VjdXJlIG5vaXNlIHBzayBoZXJlIQ==" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-zwave-proxy", + CONF_NOISE_PSK: noise_psk, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) device_info = { "name": "test-zwave-proxy", @@ -2739,6 +2752,7 @@ async def test_zwave_proxy_request_home_id_change( await mock_esphome_device( mock_client=mock_client, + entry=entry, device_info=device_info, ) await hass.async_block_till_done() @@ -2771,6 +2785,10 @@ async def test_zwave_proxy_request_home_id_change( # Verify no flow was created for non-HOME_ID_CHANGE requests mock_create_flow.assert_not_called() + # A dynamically provisioned key is written to the config entry but cannot + # be applied to the already connected client, so the client reports no PSK. + mock_client.noise_psk = None + # Create a mock request with HOME_ID_CHANGE type and zwave_home_id as bytes zwave_home_id = 1234567890 request = ZWaveProxyRequest( @@ -2792,6 +2810,8 @@ async def test_zwave_proxy_request_home_id_change( call_args = mock_create_flow.call_args assert call_args[0][0] == hass assert call_args[0][1] == "zwave_js" + # The noise PSK is taken from the config entry, not the live client + assert call_args[0][3].noise_psk == noise_psk async def test_no_zwave_proxy_subscribe_without_feature_flags( From 1b1a008816e38e0780b04922979cef00f797b3b9 Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 9 Jul 2026 09:28:30 +0200 Subject: [PATCH 320/707] Support response in Mastodon post action (#175126) --- homeassistant/components/mastodon/services.py | 22 +++-- tests/components/mastodon/conftest.py | 6 +- .../mastodon/fixtures/status_post.json | 84 +++++++++++++++++++ tests/components/mastodon/test_services.py | 7 +- 4 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 tests/components/mastodon/fixtures/status_post.json diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 1ceb8721fae3..b46b8be84a34 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -14,6 +14,8 @@ from mastodon.Mastodon import ( MastodonNotFoundError, MastodonUnauthorizedError, MediaAttachment, + ScheduledStatus, + Status, ) import voluptuous as vol @@ -177,7 +179,11 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_UNMUTE_ACCOUNT_SCHEMA, ) hass.services.async_register( - DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA + DOMAIN, + SERVICE_POST, + _async_post, + schema=SERVICE_POST_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, @@ -326,7 +332,7 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: translation_key="idempotency_key_too_short", ) - await call.hass.async_add_executor_job( + response = await call.hass.async_add_executor_job( partial( _post, hass=call.hass, @@ -344,11 +350,14 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: quoted_status_id=quoted_status, ) ) - + if call.return_response: + return response return None -def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None: +def _post( + hass: HomeAssistant, client: Mastodon, **kwargs: Any +) -> Status | ScheduledStatus: """Post to Mastodon.""" media_data: MediaAttachment | None = None @@ -385,12 +394,15 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None: if media_data: media_ids = media_data.id try: - client.status_post(media_ids=media_ids, **kwargs) + response: Status | ScheduledStatus = client.status_post( + media_ids=media_ids, **kwargs + ) except MastodonAPIError as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="unable_to_send_message", ) from err + return response async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None: diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index bd272c2a6423..038b5d3f3ef4 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from mastodon.Mastodon import Account, InstanceV2 +from mastodon.Mastodon import Account, InstanceV2, Status import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN @@ -45,7 +45,9 @@ def mock_mastodon_client() -> Generator[AsyncMock]: load_fixture("account.json", DOMAIN) ) client.mastodon_api_version = 2 - client.status_post.return_value = None + client.status_post.return_value = Status.from_json( + load_fixture("status_post.json", DOMAIN) + ) client.account_update_credentials.return_value = Account.from_json( load_fixture("account.json", DOMAIN) diff --git a/tests/components/mastodon/fixtures/status_post.json b/tests/components/mastodon/fixtures/status_post.json new file mode 100644 index 000000000000..5a38387d98e3 --- /dev/null +++ b/tests/components/mastodon/fixtures/status_post.json @@ -0,0 +1,84 @@ +{ + "_mastopy_version": "2.0.0", + "_mastopy_type": "Status", + "_mastopy_data": { + "id": "116388626899248381", + "created_at": "2026-04-11T23:18:27.129Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": true, + "spoiler_text": "", + "visibility": "unlisted", + "language": "en", + "uri": "http://localhost:3000/ap/users/116387031229467654/statuses/116388626899248381", + "url": "http://localhost:3000/@mastodonpy_test/116388626899248381", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "quotes_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "text": "Toot!", + "filtered": [], + "reblog": null, + "application": { + "name": "Mastodon.py test suite", + "website": null + }, + "account": { + "id": "116387031229467654", + "username": "mastodonpy_test", + "acct": "mastodonpy_test", + "display_name": "John Lennon", + "locked": true, + "bot": false, + "discoverable": null, + "indexable": true, + "group": false, + "created_at": "2026-04-11T00:00:00.000Z", + "note": "\u003cp\u003eI walk funny\u003c/p\u003e", + "url": "http://localhost:3000/@mastodonpy_test", + "uri": "http://localhost:3000/ap/users/116387031229467654", + "avatar": "http://localhost:3000/system/accounts/avatars/116/387/031/229/467/654/original/876440ecc8bd08c0.jpg", + "avatar_static": "http://localhost:3000/system/accounts/avatars/116/387/031/229/467/654/original/876440ecc8bd08c0.jpg", + "header": "http://localhost:3000/system/accounts/headers/116/387/031/229/467/654/original/82144469ad0970a5.jpg", + "header_static": "http://localhost:3000/system/accounts/headers/116/387/031/229/467/654/original/82144469ad0970a5.jpg", + "followers_count": 0, + "following_count": 0, + "statuses_count": 26, + "last_status_at": "2026-04-11", + "hide_collections": true, + "noindex": false, + "emojis": [], + "roles": [], + "fields": [ + { + "name": "bread", + "value": "toasty.", + "verified_at": null + }, + { + "name": "lasagna", + "value": "no!!!", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "quote": null, + "card": null, + "poll": null, + "quote_approval": { + "automatic": ["followers"], + "manual": [], + "current_user": "automatic" + } + } +} diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index b44c97589eda..e868c3fcc94f 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -546,12 +546,14 @@ async def test_unmute_account_failure_api_error( ), ], ) +@pytest.mark.parametrize("return_response", [True, False]) async def test_service_post( hass: HomeAssistant, mock_mastodon_client: AsyncMock, mock_config_entry: MockConfigEntry, payload: dict[str, str], kwargs: dict[str, str | None], + return_response: bool, ) -> None: """Test the post service.""" @@ -563,7 +565,7 @@ async def test_service_post( mock_mastodon_client, "media_post", return_value=MediaAttachment(id="1") ), ): - await hass.services.async_call( + response = await hass.services.async_call( DOMAIN, SERVICE_POST, { @@ -571,12 +573,13 @@ async def test_service_post( } | payload, blocking=True, - return_response=False, + return_response=return_response, ) mock_mastodon_client.status_post.assert_called_with(**kwargs) mock_mastodon_client.status_post.reset_mock() + assert bool(response) is return_response @pytest.mark.parametrize( From f69fd14f4b39a1588990417c3ce7269dcab23563 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:31:55 +0200 Subject: [PATCH 321/707] Use ZoneEntityStateAttribute enum in device_tracker (#175976) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/device_tracker/entity.py | 10 +++++++--- homeassistant/components/device_tracker/legacy.py | 8 +++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index 7f1b77b37198..0b4b8bde1e0c 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, final, override from propcache.api import cached_property from homeassistant.components import zone -from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS +from homeassistant.components.zone import ZoneEntityStateAttribute from homeassistant.const import ( # noqa: F401 ATTR_BATTERY_LEVEL, ATTR_GPS_ACCURACY, @@ -368,10 +368,14 @@ class TrackerEntity( for entity_id in zones if (zone_state := self.hass.states.get(entity_id)) is not None ), - key=lambda z: z.attributes[ATTR_RADIUS], + key=lambda z: z.attributes[ZoneEntityStateAttribute.RADIUS], ) self.__active_zone = next( - (z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)), + ( + z + for z in zone_states + if not z.attributes.get(ZoneEntityStateAttribute.PASSIVE) + ), None, ) self.__in_zones = [z.entity_id for z in zone_states] diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 8abb4a44f3d3..0700db8e18e4 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import util from homeassistant.components import zone -from homeassistant.components.zone import ENTITY_ID_HOME +from homeassistant.components.zone import ENTITY_ID_HOME, ZoneEntityStateAttribute from homeassistant.config import ( async_log_schema_error, config_per_platform, @@ -24,8 +24,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, ATTR_ICON, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_NAME, CONF_ICON, CONF_MAC, @@ -511,8 +509,8 @@ def async_setup_scanner_platform( zone_home = hass.states.get(ENTITY_ID_HOME) if zone_home is not None: kwargs["gps"] = [ - zone_home.attributes[ATTR_LATITUDE], - zone_home.attributes[ATTR_LONGITUDE], + zone_home.attributes[ZoneEntityStateAttribute.LATITUDE], + zone_home.attributes[ZoneEntityStateAttribute.LONGITUDE], ] kwargs["gps_accuracy"] = 0 From 71e9a0e663123cffca2ba28bd5540e9d33a58928 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:33:16 +0200 Subject: [PATCH 322/707] Use ZoneEntityStateAttribute enum in OpenAI Conversation (#175980) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/openai_conversation/config_flow.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index e2aa19b4ad23..da496037fed2 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -9,7 +9,7 @@ import openai import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components.zone import ENTITY_ID_HOME +from homeassistant.components.zone import ENTITY_ID_HOME, ZoneEntityStateAttribute from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -19,14 +19,7 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_API_KEY, - CONF_LLM_HASS_API, - CONF_NAME, - CONF_PROMPT, -) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import get_async_client @@ -654,8 +647,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): { "role": "system", "content": "Where are the following coordinates located: " - f"({zone_home.attributes[ATTR_LATITUDE]}," - f" {zone_home.attributes[ATTR_LONGITUDE]})?", + f"({zone_home.attributes[ZoneEntityStateAttribute.LATITUDE]}," + f" {zone_home.attributes[ZoneEntityStateAttribute.LONGITUDE]})?", } ], text={ From 07ac5e6e9698db93d38676b8a90666e357df5845 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:34:30 +0200 Subject: [PATCH 323/707] Use entity state attribute enums in universal (#175987) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/universal/media_player.py | 95 ++++++++++--------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 1b84bbfb7cfd..71ffe80206f8 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -6,28 +6,13 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.media_player import ( - ATTR_APP_ID, - ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_ALBUM_ARTIST, - ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, - ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - ATTR_MEDIA_DURATION, - ATTR_MEDIA_EPISODE, - ATTR_MEDIA_PLAYLIST, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, - ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, - ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_TITLE, - ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, @@ -41,16 +26,15 @@ from homeassistant.components.media_player import ( SERVICE_SELECT_SOURCE, BrowseMedia, MediaPlayerEntity, + MediaPlayerEntityCapabilityAttribute, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, MediaPlayerState, MediaType, RepeatMode, ) from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, - ATTR_ENTITY_PICTURE, - ATTR_SUPPORTED_FEATURES, CONF_DEVICE_CLASS, CONF_NAME, CONF_STATE, @@ -76,6 +60,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback @@ -318,7 +303,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" - return self._child_attr(ATTR_ASSUMED_STATE) + return self._child_attr(EntityStateAttribute.ASSUMED_STATE) @property @override @@ -343,7 +328,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): def volume_level(self): """Volume level of entity specified in attributes or active child.""" try: - return float(self._override_or_child_attr(ATTR_MEDIA_VOLUME_LEVEL)) + return float( + self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL + ) + ) except TypeError, ValueError: return None @@ -351,31 +340,33 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def is_volume_muted(self): """Boolean if volume is muted.""" - return self._override_or_child_attr(ATTR_MEDIA_VOLUME_MUTED) in [True, STATE_ON] + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED + ) in [True, STATE_ON] @property @override def media_content_id(self): """Return the content ID of current playing media.""" - return self._child_attr(ATTR_MEDIA_CONTENT_ID) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID) @property @override def media_content_type(self): """Return the content type of current playing media.""" - return self._child_attr(ATTR_MEDIA_CONTENT_TYPE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE) @property @override def media_duration(self): """Return the duration of current playing media in seconds.""" - return self._child_attr(ATTR_MEDIA_DURATION) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_DURATION) @property @override def media_image_url(self): """Image url of current playing media.""" - return self._override_or_child_attr(ATTR_ENTITY_PICTURE) + return self._override_or_child_attr(EntityStateAttribute.ENTITY_PICTURE) @property @override @@ -392,116 +383,126 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def media_title(self): """Title of current playing media.""" - return self._child_attr(ATTR_MEDIA_TITLE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_TITLE) @property @override def media_artist(self): """Artist of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_ARTIST) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ARTIST) @property @override def media_album_name(self): """Album name of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_ALBUM_NAME) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME) @property @override def media_album_artist(self): """Album artist of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_ALBUM_ARTIST) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ALBUM_ARTIST) @property @override def media_track(self): """Track number of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_TRACK) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_TRACK) @property @override def media_series_title(self): """Return the title of the series of current playing media (TV).""" - return self._child_attr(ATTR_MEDIA_SERIES_TITLE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_SERIES_TITLE) @property @override def media_season(self): """Season of current playing media (TV Show only).""" - return self._child_attr(ATTR_MEDIA_SEASON) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_SEASON) @property @override def media_episode(self): """Episode of current playing media (TV Show only).""" - return self._child_attr(ATTR_MEDIA_EPISODE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_EPISODE) @property @override def media_channel(self): """Channel currently playing.""" - return self._child_attr(ATTR_MEDIA_CHANNEL) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CHANNEL) @property @override def media_playlist(self): """Title of Playlist currently playing.""" - return self._child_attr(ATTR_MEDIA_PLAYLIST) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_PLAYLIST) @property @override def app_id(self): """ID of the current running app.""" - return self._child_attr(ATTR_APP_ID) + return self._child_attr(MediaPlayerEntityStateAttribute.APP_ID) @property @override def app_name(self): """Name of the current running app.""" - return self._child_attr(ATTR_APP_NAME) + return self._child_attr(MediaPlayerEntityStateAttribute.APP_NAME) @property @override def sound_mode(self): """Return the current sound mode of the device.""" - return self._override_or_child_attr(ATTR_SOUND_MODE) + return self._override_or_child_attr(MediaPlayerEntityStateAttribute.SOUND_MODE) @property @override def sound_mode_list(self): """List of available sound modes.""" - return self._override_or_child_attr(ATTR_SOUND_MODE_LIST) + return self._override_or_child_attr( + MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST + ) @property @override def source(self): """Return the current input source of the device.""" - return self._override_or_child_attr(ATTR_INPUT_SOURCE) + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.INPUT_SOURCE + ) @property @override def source_list(self): """List of available input sources.""" - return self._override_or_child_attr(ATTR_INPUT_SOURCE_LIST) + return self._override_or_child_attr( + MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST + ) @property @override def repeat(self): """Boolean if repeating is enabled.""" - return self._override_or_child_attr(ATTR_MEDIA_REPEAT) + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_REPEAT + ) @property @override def shuffle(self): """Boolean if shuffling is enabled.""" - return self._override_or_child_attr(ATTR_MEDIA_SHUFFLE) + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_SHUFFLE + ) @property @override def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" flags: MediaPlayerEntityFeature = self._child_attr( - ATTR_SUPPORTED_FEATURES + EntityStateAttribute.SUPPORTED_FEATURES ) or MediaPlayerEntityFeature(0) if SERVICE_TURN_ON in self._cmds: @@ -573,13 +574,15 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def media_position(self): """Position of current playing media in seconds.""" - return self._child_attr(ATTR_MEDIA_POSITION) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_POSITION) @property @override def media_position_updated_at(self): """When was the position of the current playing media valid.""" - return self._child_attr(ATTR_MEDIA_POSITION_UPDATED_AT) + return self._child_attr( + MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT + ) @override async def async_turn_on(self) -> None: From eaffb9c78bc180024b126498baaccf43c93e420f Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 9 Jul 2026 09:40:16 +0200 Subject: [PATCH 324/707] Add binary sensor platform to Fronius with backup mode (#176056) Co-authored-by: Claude Opus 4.8 --- homeassistant/components/fronius/__init__.py | 2 +- .../components/fronius/binary_sensor.py | 85 ++++++++ .../components/fronius/coordinator.py | 41 ++-- homeassistant/components/fronius/entity.py | 42 ++++ homeassistant/components/fronius/icons.json | 8 + homeassistant/components/fronius/sensor.py | 38 ++-- homeassistant/components/fronius/strings.json | 8 + .../fronius/snapshots/test_binary_sensor.ambr | 201 ++++++++++++++++++ .../components/fronius/test_binary_sensor.py | 54 +++++ tests/components/fronius/test_sensor.py | 13 +- 10 files changed, 448 insertions(+), 44 deletions(-) create mode 100644 homeassistant/components/fronius/binary_sensor.py create mode 100644 homeassistant/components/fronius/entity.py create mode 100644 tests/components/fronius/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/fronius/test_binary_sensor.py diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index e88227fe33ca..ea147ee2276e 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -35,7 +35,7 @@ from .coordinator import ( ) _LOGGER: Final = logging.getLogger(__name__) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR] type FroniusConfigEntry = ConfigEntry[FroniusSolarNet] diff --git a/homeassistant/components/fronius/binary_sensor.py b/homeassistant/components/fronius/binary_sensor.py new file mode 100644 index 000000000000..4db1bced11ca --- /dev/null +++ b/homeassistant/components/fronius/binary_sensor.py @@ -0,0 +1,85 @@ +"""Support for Fronius binary sensors.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, override + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import FroniusEntity, FroniusEntityDescription + +if TYPE_CHECKING: + from . import FroniusConfigEntry + from .coordinator import FroniusPowerFlowUpdateCoordinator + + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True) +class FroniusBinarySensorEntityDescription( + FroniusEntityDescription, BinarySensorEntityDescription +): + """Describes Fronius binary sensor entity.""" + + +POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS: list[FroniusBinarySensorEntityDescription] = [ + FroniusBinarySensorEntityDescription( + key="backup_mode", + ), + FroniusBinarySensorEntityDescription( + key="battery_standby", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FroniusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fronius binary sensor entities based on a config entry.""" + solar_net = config_entry.runtime_data + if solar_net.power_flow_coordinator is not None: + solar_net.power_flow_coordinator.add_entities_for_seen_keys( + async_add_entities, Platform.BINARY_SENSOR, PowerFlowBinarySensor + ) + + +class PowerFlowBinarySensor(FroniusEntity, BinarySensorEntity): + """Defines a Fronius power flow binary sensor entity.""" + + entity_description: FroniusBinarySensorEntityDescription + + def __init__( + self, + coordinator: FroniusPowerFlowUpdateCoordinator, + description: FroniusBinarySensorEntityDescription, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius power flow binary sensor.""" + super().__init__(coordinator, description, solar_net_id) + self._attr_is_on = self._device_data()[self.response_key]["value"] + # SolarNet device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_unique_id = ( + f"{coordinator.solar_net.solar_net_device_id}-power_flow-{description.key}" + ) + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + self._attr_is_on = self._device_data()[self.response_key]["value"] + except KeyError: + # KeyError: raised when omitted in response, e.g. when backup power + # is deactivated after the entity was created + self._attr_is_on = None + self.async_write_ha_state() diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 302d278880da..17f70f8e4d4e 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -1,15 +1,18 @@ """DataUpdateCoordinators for the Fronius integration.""" from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence from datetime import timedelta from typing import TYPE_CHECKING, Any, override from pyfronius import BadStatusError, FroniusError +from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .binary_sensor import POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS from .const import ( DOMAIN, SOLAR_NET_ID_POWER_FLOW, @@ -17,6 +20,7 @@ from .const import ( FroniusDeviceInfo, SolarNetId, ) +from .entity import FroniusEntity, FroniusEntityDescription from .sensor import ( INVERTER_ENTITY_DESCRIPTIONS, LOGGER_ENTITY_DESCRIPTIONS, @@ -24,12 +28,10 @@ from .sensor import ( OHMPILOT_ENTITY_DESCRIPTIONS, POWER_FLOW_ENTITY_DESCRIPTIONS, STORAGE_ENTITY_DESCRIPTIONS, - FroniusSensorEntityDescription, ) if TYPE_CHECKING: from . import FroniusSolarNet - from .sensor import _FroniusSensorEntity class FroniusCoordinatorBase( @@ -39,7 +41,7 @@ class FroniusCoordinatorBase( default_interval: timedelta error_interval: timedelta - valid_descriptions: list[FroniusSensorEntityDescription] + valid_descriptions: Mapping[Platform, Sequence[FroniusEntityDescription]] MAX_FAILED_UPDATES = 3 @@ -49,7 +51,7 @@ class FroniusCoordinatorBase( self.solar_net = solar_net # unregistered_descriptors are used to create entities in platform module self.unregistered_descriptors: dict[ - SolarNetId, list[FroniusSensorEntityDescription] + SolarNetId, dict[Platform, list[FroniusEntityDescription]] ] = {} super().__init__(*args, update_interval=self.default_interval, **kwargs) @@ -80,15 +82,17 @@ class FroniusCoordinatorBase( for solar_net_id in data: if solar_net_id not in self.unregistered_descriptors: # id seen for the first time - self.unregistered_descriptors[solar_net_id] = ( - self.valid_descriptions.copy() - ) + self.unregistered_descriptors[solar_net_id] = { + platform: list(descriptions) + for platform, descriptions in self.valid_descriptions.items() + } return data @callback - def add_entities_for_seen_keys[_FroniusEntityT: _FroniusSensorEntity]( + def add_entities_for_seen_keys[_FroniusEntityT: FroniusEntity]( self, async_add_entities: AddEntitiesCallback, + platform: Platform, entity_constructor: type[_FroniusEntityT], ) -> None: """Add entities for received keys and registers listener for future seen keys. @@ -102,7 +106,9 @@ class FroniusCoordinatorBase( new_entities: list[_FroniusEntityT] = [] for solar_net_id, device_data in self.data.items(): remaining_unregistered_descriptors = [] - for description in self.unregistered_descriptors[solar_net_id]: + for description in self.unregistered_descriptors[solar_net_id][ + platform + ]: key = description.response_key or description.key if key not in device_data: remaining_unregistered_descriptors.append(description) @@ -117,7 +123,7 @@ class FroniusCoordinatorBase( solar_net_id=solar_net_id, ) ) - self.unregistered_descriptors[solar_net_id] = ( + self.unregistered_descriptors[solar_net_id][platform] = ( remaining_unregistered_descriptors ) async_add_entities(new_entities) @@ -133,7 +139,7 @@ class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = INVERTER_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: INVERTER_ENTITY_DESCRIPTIONS} SILENT_RETRIES = 3 @@ -170,7 +176,7 @@ class FroniusLoggerUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(hours=1) error_interval = timedelta(hours=1) - valid_descriptions = LOGGER_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: LOGGER_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -184,7 +190,7 @@ class FroniusMeterUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = METER_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: METER_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -198,7 +204,7 @@ class FroniusOhmpilotUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: OHMPILOT_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -212,7 +218,10 @@ class FroniusPowerFlowUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(seconds=10) error_interval = timedelta(minutes=3) - valid_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS + valid_descriptions = { + Platform.SENSOR: POWER_FLOW_ENTITY_DESCRIPTIONS, + Platform.BINARY_SENSOR: POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS, + } @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -226,7 +235,7 @@ class FroniusStorageUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = STORAGE_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: STORAGE_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: diff --git a/homeassistant/components/fronius/entity.py b/homeassistant/components/fronius/entity.py new file mode 100644 index 000000000000..daa7bac527bc --- /dev/null +++ b/homeassistant/components/fronius/entity.py @@ -0,0 +1,42 @@ +"""Base entity for the Fronius integration.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +if TYPE_CHECKING: + from .coordinator import FroniusCoordinatorBase + + +@dataclass(frozen=True) +class FroniusEntityDescription(EntityDescription): + """Base class for Fronius entity descriptions.""" + + response_key: str | None = None + + +class FroniusEntity(CoordinatorEntity["FroniusCoordinatorBase"]): + """Defines a Fronius coordinator entity.""" + + entity_description: FroniusEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FroniusCoordinatorBase, + description: FroniusEntityDescription, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius coordinator entity.""" + super().__init__(coordinator) + self.entity_description = description + self.response_key = description.response_key or description.key + self.solar_net_id = solar_net_id + self._attr_translation_key = description.translation_key or description.key + + def _device_data(self) -> dict[str, Any]: + """Extract information for SolarNet device from coordinator data.""" + return self.coordinator.data[self.solar_net_id] diff --git a/homeassistant/components/fronius/icons.json b/homeassistant/components/fronius/icons.json index e899dcf32822..40d598b2a528 100644 --- a/homeassistant/components/fronius/icons.json +++ b/homeassistant/components/fronius/icons.json @@ -1,5 +1,13 @@ { "entity": { + "binary_sensor": { + "backup_mode": { + "default": "mdi:home-battery" + }, + "battery_standby": { + "default": "mdi:power-standby" + } + }, "sensor": { "cash_factor": { "default": "mdi:cash-plus" diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 8a32acaee6aa..6bcbde7cd72c 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( PERCENTAGE, EntityCategory, + Platform, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -27,7 +28,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, @@ -40,6 +40,7 @@ from .const import ( get_meter_location_description, get_ohmpilot_state_message, ) +from .entity import FroniusEntity, FroniusEntityDescription if TYPE_CHECKING: from . import FroniusConfigEntry @@ -69,33 +70,35 @@ async def async_setup_entry( for inverter_coordinator in solar_net.inverter_coordinators: inverter_coordinator.add_entities_for_seen_keys( - async_add_entities, InverterSensor + async_add_entities, Platform.SENSOR, InverterSensor ) if solar_net.logger_coordinator is not None: solar_net.logger_coordinator.add_entities_for_seen_keys( - async_add_entities, LoggerSensor + async_add_entities, Platform.SENSOR, LoggerSensor ) if solar_net.meter_coordinator is not None: solar_net.meter_coordinator.add_entities_for_seen_keys( - async_add_entities, MeterSensor + async_add_entities, Platform.SENSOR, MeterSensor ) if solar_net.ohmpilot_coordinator is not None: solar_net.ohmpilot_coordinator.add_entities_for_seen_keys( - async_add_entities, OhmpilotSensor + async_add_entities, Platform.SENSOR, OhmpilotSensor ) if solar_net.power_flow_coordinator is not None: solar_net.power_flow_coordinator.add_entities_for_seen_keys( - async_add_entities, PowerFlowSensor + async_add_entities, Platform.SENSOR, PowerFlowSensor ) if solar_net.storage_coordinator is not None: solar_net.storage_coordinator.add_entities_for_seen_keys( - async_add_entities, StorageSensor + async_add_entities, Platform.SENSOR, StorageSensor ) @callback def async_add_new_entities(coordinator: FroniusInverterUpdateCoordinator) -> None: """Add newly found inverter entities.""" - coordinator.add_entities_for_seen_keys(async_add_entities, InverterSensor) + coordinator.add_entities_for_seen_keys( + async_add_entities, Platform.SENSOR, InverterSensor + ) config_entry.async_on_unload( async_dispatcher_connect( @@ -107,14 +110,13 @@ async def async_setup_entry( @dataclass(frozen=True) -class FroniusSensorEntityDescription(SensorEntityDescription): +class FroniusSensorEntityDescription(FroniusEntityDescription, SensorEntityDescription): """Describes Fronius sensor entity.""" default_value: StateType | None = None # Gen24 devices may report 0 for total energy while doing firmware updates. # Handling such values shall mitigate spikes in delta calculations. invalid_when_falsy: bool = False - response_key: str | None = None value_fn: Callable[[StateType], StateType] | None = None @@ -746,13 +748,11 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ] -class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity): - """Defines a Fronius coordinator entity.""" +class _FroniusSensorEntity(FroniusEntity, SensorEntity): + """Defines a Fronius coordinator sensor entity.""" entity_description: FroniusSensorEntityDescription - _attr_has_entity_name = True - def __init__( self, coordinator: FroniusCoordinatorBase, @@ -760,16 +760,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator) - self.entity_description = description - self.response_key = description.response_key or description.key - self.solar_net_id = solar_net_id + super().__init__(coordinator, description, solar_net_id) self._attr_native_value = self._get_entity_value() - self._attr_translation_key = description.translation_key or description.key - - def _device_data(self) -> dict[str, Any]: - """Extract information for SolarNet device from coordinator data.""" - return self.coordinator.data[self.solar_net_id] def _get_entity_value(self) -> Any: """Extract entity value from coordinator. diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 2c742d0d89c9..44358e1d1845 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -36,6 +36,14 @@ } }, "entity": { + "binary_sensor": { + "backup_mode": { + "name": "Backup mode" + }, + "battery_standby": { + "name": "Battery standby" + } + }, "sensor": { "capacity_designed": { "name": "Designed capacity" diff --git a/tests/components/fronius/snapshots/test_binary_sensor.ambr b/tests/components/fronius/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..b87ef268a49f --- /dev/null +++ b/tests/components/fronius/snapshots/test_binary_sensor.ambr @@ -0,0 +1,201 @@ +# serializer version: 1 +# name: test_binary_sensors[gen24][binary_sensor.solarnet_backup_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.solarnet_backup_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backup mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backup mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backup_mode', + 'unique_id': 'solar_net_123.4567890-power_flow-backup_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24][binary_sensor.solarnet_backup_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Backup mode', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_backup_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[gen24][binary_sensor.solarnet_battery_standby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.solarnet_battery_standby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery standby', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery standby', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_standby', + 'unique_id': 'solar_net_123.4567890-power_flow-battery_standby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24][binary_sensor.solarnet_battery_standby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Battery standby', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_battery_standby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_backup_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.solarnet_backup_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backup mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backup mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backup_mode', + 'unique_id': 'solar_net_12345678-power_flow-backup_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_backup_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Backup mode', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_backup_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_battery_standby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.solarnet_battery_standby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery standby', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery standby', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_standby', + 'unique_id': 'solar_net_12345678-power_flow-battery_standby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_battery_standby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Battery standby', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_battery_standby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fronius/test_binary_sensor.py b/tests/components/fronius/test_binary_sensor.py new file mode 100644 index 000000000000..902e9b3308c2 --- /dev/null +++ b/tests/components/fronius/test_binary_sensor.py @@ -0,0 +1,54 @@ +"""Tests for the Fronius binary sensor platform.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MOCK_UID, mock_responses, setup_fronius_integration + +from tests.common import snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("fixture_set", "unique_id"), + [ + pytest.param("gen24", MOCK_UID, id="gen24"), + pytest.param("gen24_storage", "12345678", id="gen24_storage"), + ], +) +async def test_binary_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fixture_set: str, + unique_id: str, +) -> None: + """Test Fronius power flow binary sensors for Gen24 devices.""" + mock_responses(aioclient_mock, fixture_set=fixture_set) + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.BINARY_SENSOR]): + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id=unique_id + ) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_no_binary_sensors_without_backup_keys( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that no binary sensors are created when the API omits the keys.""" + # The Symo power flow response has neither BackupMode nor BatteryStandby. + mock_responses(aioclient_mock, fixture_set="symo") + await setup_fronius_integration(hass, is_logger=True) + + assert not hass.states.async_all(domain_filter=BINARY_SENSOR_DOMAIN) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index be8cd43cf2bb..0d226de76c97 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -1,5 +1,7 @@ """Tests for the Fronius sensor platform.""" +from unittest.mock import patch + from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -10,6 +12,7 @@ from homeassistant.components.fronius.coordinator import ( FroniusPowerFlowUpdateCoordinator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -269,7 +272,8 @@ async def test_gen24( assert state.state == str(expected_state) mock_responses(aioclient_mock, fixture_set="gen24") - config_entry = await setup_fronius_integration(hass, is_logger=False) + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]): + config_entry = await setup_fronius_integration(hass, is_logger=False) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @@ -309,9 +313,10 @@ async def test_gen24_storage( assert state.state == str(expected_state) mock_responses(aioclient_mock, fixture_set="gen24_storage") - config_entry = await setup_fronius_integration( - hass, is_logger=False, unique_id="12345678" - ) + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]): + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 73 await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From f1dbf30d29f2f520017439cca8399cf42369d97b Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Thu, 9 Jul 2026 01:03:31 -0700 Subject: [PATCH 325/707] Add additional Subaru sensors and value_fn description support (#174054) Co-authored-by: Claude Opus 4.7 --- homeassistant/components/subaru/const.py | 8 ++ homeassistant/components/subaru/sensor.py | 128 +++++++++++++++--- homeassistant/components/subaru/strings.json | 15 ++ tests/components/subaru/api_responses.py | 22 +-- .../subaru/snapshots/test_diagnostics.ambr | 12 ++ tests/components/subaru/test_sensor.py | 86 +++++++++++- 6 files changed, 243 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 53148f9a00ad..d1a1662d6474 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -24,6 +24,14 @@ VEHICLE_HAS_REMOTE_SERVICE = "has_remote" VEHICLE_HAS_SAFETY_SERVICE = "has_safety" VEHICLE_LAST_UPDATE = "last_update" VEHICLE_STATUS = "vehicle_status" +VEHICLE_HEALTH = "vehicle_health" + +# Synthetic keys for sensors that don't read a single field directly; used +# as both unique_id suffix and translation_key, so they must stay stable +# across releases (changing them would orphan existing entity registry +# entries). +KEY_RECOMMENDED_TIRE_PRESSURE_FRONT = "recommended_tire_pressure_front" +KEY_RECOMMENDED_TIRE_PRESSURE_REAR = "recommended_tire_pressure_rear" API_GEN_1 = "g1" diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index ccf99fc46500..1a49fbba510d 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -1,5 +1,9 @@ """Support for Subaru sensors.""" +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal import logging from typing import Any, override @@ -12,10 +16,17 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure, UnitOfVolume +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfLength, + UnitOfPressure, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import METRIC_SYSTEM @@ -25,8 +36,11 @@ from .const import ( API_GEN_2, API_GEN_3, API_GEN_4, + KEY_RECOMMENDED_TIRE_PRESSURE_FRONT, + KEY_RECOMMENDED_TIRE_PRESSURE_REAR, VEHICLE_API_GEN, VEHICLE_HAS_EV, + VEHICLE_HEALTH, VEHICLE_STATUS, VEHICLE_VIN, ) @@ -42,9 +56,56 @@ FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" L_PER_GAL = VolumeConverter.convert(1, UnitOfVolume.GALLONS, UnitOfVolume.LITERS) KM_PER_MI = DistanceConverter.convert(1, UnitOfLength.MILES, UnitOfLength.KILOMETERS) +# Readable aliases for subarulink field-key constants. +API_KEY_VEHICLE_STATE_TYPE = sc.VEHICLE_STATE +API_KEY_RECOMMENDED_TIRE_PRESSURE = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE +API_KEY_FRONT_TIRES = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE_FRONT +API_KEY_REAR_TIRES = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE_REAR + + +@dataclass(frozen=True, kw_only=True) +class SubaruSensorEntityDescription(SensorEntityDescription): + """Describes a Subaru sensor entity.""" + + value_fn: Callable[[dict[str, Any]], StateType] | None = None + + +def _recommended_tire_pressure(axle: str) -> Callable[[dict[str, Any]], StateType]: + """Return a getter for recommended FRONT or REAR axle tire pressure from vehicle_health.""" + + def getter(data: dict[str, Any]) -> StateType: + health = data.get(VEHICLE_HEALTH) or {} + recommended = health.get(API_KEY_RECOMMENDED_TIRE_PRESSURE) or {} + return recommended.get(axle) + + return getter + + +# Snake-case ENUM options for vehicle_state. Authoritative values confirmed by +# the integration codeowner against the Subaru Android app source (see PR +# #174054 discussion_r3488335137 and subarulink PR G-Two/subarulink#121). +# IGN-ACC, IGN-ON, and ENGINE_ON_REMOTE_START are not yet exported as +# `sc.*` constants in the released subarulink; the literal strings will be +# replaced once the next subarulink pin lands. +VEHICLE_STATE_OPTIONS = { + sc.IGNITION_OFF: "ignition_off", + "IGN-ACC": "ignition_acc", + "IGN-ON": "ignition_on", + "ENGINE_ON_REMOTE_START": "engine_on_remote_start", +} + + +def _vehicle_state_enum(data: dict[str, Any]) -> StateType: + """Map the raw VEHICLE_STATE_TYPE to a snake_case ENUM option (unmapped → None → `unknown`).""" + raw = (data.get(VEHICLE_STATUS) or {}).get(API_KEY_VEHICLE_STATE_TYPE) + if raw is None: + return None + return VEHICLE_STATE_OPTIONS.get(raw) + + # Sensor available for Gen1 or Gen2 vehicles SAFETY_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.ODOMETER, translation_key="odometer", device_class=SensorDeviceClass.DISTANCE, @@ -55,52 +116,78 @@ SAFETY_SENSORS = [ # Sensors available to subscribers with Gen2/Gen3 vehicles API_GEN_2_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, translation_key="average_fuel_consumption", native_unit_of_measurement=FUEL_CONSUMPTION_MILES_PER_GALLON, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.DIST_TO_EMPTY, translation_key="range", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_FL, translation_key="tire_pressure_front_left", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_FR, translation_key="tire_pressure_front_right", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_RL, translation_key="tire_pressure_rear_left", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_RR, translation_key="tire_pressure_rear_right", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), + SubaruSensorEntityDescription( + key=API_KEY_VEHICLE_STATE_TYPE, + translation_key="vehicle_state", + device_class=SensorDeviceClass.ENUM, + options=sorted(VEHICLE_STATE_OPTIONS.values()), + value_fn=_vehicle_state_enum, + ), + # Static manufacturer reference value, not a live measurement; no state_class. + SubaruSensorEntityDescription( + key=KEY_RECOMMENDED_TIRE_PRESSURE_FRONT, + translation_key=KEY_RECOMMENDED_TIRE_PRESSURE_FRONT, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=_recommended_tire_pressure(API_KEY_FRONT_TIRES), + ), + SubaruSensorEntityDescription( + key=KEY_RECOMMENDED_TIRE_PRESSURE_REAR, + translation_key=KEY_RECOMMENDED_TIRE_PRESSURE_REAR, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=_recommended_tire_pressure(API_KEY_REAR_TIRES), + ), ] # Sensors available for Gen3 vehicles API_GEN_3_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.REMAINING_FUEL_PERCENT, translation_key="fuel_level", native_unit_of_measurement=PERCENTAGE, @@ -110,21 +197,21 @@ API_GEN_3_SENSORS = [ # Sensors available to subscribers with PHEV vehicles EV_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, translation_key="ev_range", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.EV_STATE_OF_CHARGE_PERCENT, translation_key="ev_battery_level", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.EV_TIME_TO_FULLY_CHARGED_UTC, translation_key="ev_time_to_full_charge", device_class=SensorDeviceClass.TIMESTAMP, @@ -177,12 +264,13 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity) """Class for Subaru sensors.""" _attr_has_entity_name = True + entity_description: SubaruSensorEntityDescription def __init__( self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator, - description: SensorEntityDescription, + description: SubaruSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -193,14 +281,20 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity) @property @override - def native_value(self) -> int | float | None: + def native_value(self) -> StateType | date | datetime | Decimal: """Return the state of the sensor.""" - current_value = self.coordinator.data[self.vin][VEHICLE_STATUS].get( - self.entity_description.key - ) + vehicle_data = self.coordinator.data[self.vin] + if self.entity_description.value_fn is not None: + current_value = self.entity_description.value_fn(vehicle_data) + else: + current_value = vehicle_data[VEHICLE_STATUS].get( + self.entity_description.key + ) if ( self.entity_description.key == sc.AVG_FUEL_CONSUMPTION + and isinstance(current_value, (int, float)) + and current_value > 0 and self.hass.config.units == METRIC_SYSTEM ): return round((100.0 * L_PER_GAL) / (KM_PER_MI * current_value), 1) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 5e72848e46b3..fd8209f150ac 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -82,6 +82,12 @@ "range": { "name": "Range" }, + "recommended_tire_pressure_front": { + "name": "Recommended tire pressure front" + }, + "recommended_tire_pressure_rear": { + "name": "Recommended tire pressure rear" + }, "tire_pressure_front_left": { "name": "Tire pressure front left" }, @@ -93,6 +99,15 @@ }, "tire_pressure_rear_right": { "name": "Tire pressure rear right" + }, + "vehicle_state": { + "name": "Vehicle state", + "state": { + "engine_on_remote_start": "Engine on (remote start)", + "ignition_acc": "Accessory power", + "ignition_off": "Ignition off", + "ignition_on": "Ignition on" + } } } }, diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index 26d1182fe571..c897ec328877 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -12,6 +12,7 @@ from homeassistant.components.subaru.const import ( VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_HEALTH, VEHICLE_MODEL_NAME, VEHICLE_MODEL_YEAR, VEHICLE_NAME, @@ -107,7 +108,10 @@ VEHICLE_STATUS_EV = { "WINDOW_SUNROOF_STATUS": "UNKNOWN", "LATITUDE": 40.0, "LONGITUDE": -100.0, - } + }, + VEHICLE_HEALTH: { + "RECOMMENDED_TIRE_PRESSURE": {"FRONT_TIRES": 35, "REAR_TIRES": 33}, + }, } @@ -144,12 +148,10 @@ VEHICLE_STATUS_G3 = { EXPECTED_STATE_EV_IMPERIAL = { "AVG_FUEL_CONSUMPTION": "51.1", "DISTANCE_TO_EMPTY_FUEL": "170", - "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", "EV_DISTANCE_TO_EMPTY": "1", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", - "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "ODOMETER": "1234", @@ -159,7 +161,9 @@ EXPECTED_STATE_EV_IMPERIAL = { "TYRE_PRESSURE_FRONT_RIGHT": "31.9", "TYRE_PRESSURE_REAR_LEFT": "32.6", "TYRE_PRESSURE_REAR_RIGHT": "unknown", - "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "VEHICLE_STATE_TYPE": "ignition_off", + "recommended_tire_pressure_front": "35", + "recommended_tire_pressure_rear": "33", "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -167,12 +171,10 @@ EXPECTED_STATE_EV_IMPERIAL = { EXPECTED_STATE_EV_METRIC = { "AVG_FUEL_CONSUMPTION": "4.6", "DISTANCE_TO_EMPTY_FUEL": "273.59", - "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", "EV_DISTANCE_TO_EMPTY": "1.61", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", - "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "ODOMETER": "1985.93", @@ -182,7 +184,9 @@ EXPECTED_STATE_EV_METRIC = { "TYRE_PRESSURE_FRONT_RIGHT": "219.94", "TYRE_PRESSURE_REAR_LEFT": "224.77", "TYRE_PRESSURE_REAR_RIGHT": "unknown", - "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "VEHICLE_STATE_TYPE": "ignition_off", + "recommended_tire_pressure_front": "241.32", + "recommended_tire_pressure_rear": "227.53", "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -191,12 +195,10 @@ EXPECTED_STATE_EV_METRIC = { EXPECTED_STATE_EV_UNAVAILABLE = { "AVG_FUEL_CONSUMPTION": "unavailable", "DISTANCE_TO_EMPTY_FUEL": "unavailable", - "EV_CHARGER_STATE_TYPE": "unavailable", "EV_CHARGE_SETTING_AMPERE_TYPE": "unavailable", "EV_CHARGE_VOLT_TYPE": "unavailable", "EV_DISTANCE_TO_EMPTY": "unavailable", "EV_IS_PLUGGED_IN": "unavailable", - "EV_STATE_OF_CHARGE_MODE": "unavailable", "EV_STATE_OF_CHARGE_PERCENT": "unavailable", "EV_TIME_TO_FULLY_CHARGED_UTC": "unavailable", "ODOMETER": "unavailable", @@ -207,6 +209,8 @@ EXPECTED_STATE_EV_UNAVAILABLE = { "TYRE_PRESSURE_REAR_LEFT": "unavailable", "TYRE_PRESSURE_REAR_RIGHT": "unavailable", "VEHICLE_STATE_TYPE": "unavailable", + "recommended_tire_pressure_front": "unavailable", + "recommended_tire_pressure_rear": "unavailable", "LATITUDE": "unavailable", "LONGITUDE": "unavailable", } diff --git a/tests/components/subaru/snapshots/test_diagnostics.ambr b/tests/components/subaru/snapshots/test_diagnostics.ambr index 14c19dd78a9f..b6cf9336d2f1 100644 --- a/tests/components/subaru/snapshots/test_diagnostics.ambr +++ b/tests/components/subaru/snapshots/test_diagnostics.ambr @@ -10,6 +10,12 @@ }), 'data': list([ dict({ + 'vehicle_health': dict({ + 'RECOMMENDED_TIRE_PRESSURE': dict({ + 'FRONT_TIRES': 35, + 'REAR_TIRES': 33, + }), + }), 'vehicle_status': dict({ 'AVG_FUEL_CONSUMPTION': 51.1, 'DISTANCE_TO_EMPTY_FUEL': 170, @@ -61,6 +67,12 @@ 'username': '**REDACTED**', }), 'data': dict({ + 'vehicle_health': dict({ + 'RECOMMENDED_TIRE_PRESSURE': dict({ + 'FRONT_TIRES': 35, + 'REAR_TIRES': 33, + }), + }), 'vehicle_status': dict({ 'AVG_FUEL_CONSUMPTION': 51.1, 'DISTANCE_TO_EMPTY_FUEL': 170, diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index 73c06b4d86ed..66e6b4c25064 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -1,17 +1,19 @@ """Test Subaru sensors.""" +import copy from typing import Any from unittest.mock import patch import pytest from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.subaru.const import DOMAIN +from homeassistant.components.subaru.const import DOMAIN, VEHICLE_STATUS from homeassistant.components.subaru.sensor import ( API_GEN_2_SENSORS, EV_SENSORS, SAFETY_SENSORS, ) +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -19,6 +21,7 @@ from .api_responses import ( EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, TEST_VIN_2_EV, + VEHICLE_STATUS_EV, ) from .conftest import ( MOCK_API_FETCH, @@ -27,7 +30,7 @@ from .conftest import ( setup_subaru_config_entry, ) -from tests.common import get_sensor_display_state +from tests.common import MockConfigEntry, get_sensor_display_state async def test_sensors_ev_metric(hass: HomeAssistant, ev_entry) -> None: @@ -137,6 +140,10 @@ def _assert_data(hass: HomeAssistant, expected_state: dict[str, Any]) -> None: expected_states = {} entity_registry = er.async_get(hass) for item in sensor_list: + # Disabled-by-default sensors (e.g. the *_raw diagnostic companions) + # aren't loaded into the state machine, so there's nothing to assert. + if not item.entity_registry_enabled_default: + continue entity = entity_registry.async_get_entity_id( SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_{item.key}" ) @@ -145,3 +152,78 @@ def _assert_data(hass: HomeAssistant, expected_state: dict[str, Any]) -> None: for sensor, value in expected_states.items(): state = get_sensor_display_state(hass, entity_registry, sensor) assert state == value + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ev_entry") +async def test_recommended_tire_pressure_from_vehicle_health( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Recommended tire pressure sensors derive their value from vehicle_health. + + These sensors are disabled-by-default diagnostics, so they're skipped in + `_assert_data`. Enable them here to exercise the `value_fn`-from- + `vehicle_health` path (the only consumer of nested-section value_fn in + the integration today). Fixture has FRONT_TIRES=35 / REAR_TIRES=33 PSI; + the test default unit system is metric, so HA's pressure conversion + surfaces them as kPa. + """ + front = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_recommended_tire_pressure_front" + ) + assert front is not None + assert ( + get_sensor_display_state(hass, entity_registry, front) + == EXPECTED_STATE_EV_METRIC["recommended_tire_pressure_front"] + ) + + rear = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_recommended_tire_pressure_rear" + ) + assert rear is not None + assert ( + get_sensor_display_state(hass, entity_registry, rear) + == EXPECTED_STATE_EV_METRIC["recommended_tire_pressure_rear"] + ) + + +async def test_avg_fuel_consumption_zero_metric( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, +) -> None: + """AVG_FUEL_CONSUMPTION of 0 returns 0 verbatim instead of dividing by zero. + + Guards the metric conversion at sensor.py:`native_value` so that a fresh + vehicle reporting 0 mpg doesn't raise ZeroDivisionError on metric installs. + """ + status_with_zero = copy.deepcopy(VEHICLE_STATUS_EV) + status_with_zero[VEHICLE_STATUS]["AVG_FUEL_CONSUMPTION"] = 0 + + await setup_subaru_config_entry( + hass, subaru_config_entry, vehicle_status=status_with_zero + ) + + state = hass.states.get("sensor.test_vehicle_2_average_fuel_consumption") + assert state is not None + assert state.state == "0" + + +async def test_enum_unmapped_value_reports_unknown( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, +) -> None: + """Unmapped ENUM values report as `unknown`. + + Live API values not in `VEHICLE_STATE_OPTIONS` fall through to `unknown` + rather than surfacing the raw upstream string as the entity state. + """ + status_with_unmapped_value = copy.deepcopy(VEHICLE_STATUS_EV) + status_with_unmapped_value[VEHICLE_STATUS]["VEHICLE_STATE_TYPE"] = "ENGINE_RUNNING" + + await setup_subaru_config_entry( + hass, subaru_config_entry, vehicle_status=status_with_unmapped_value + ) + + enum_state = hass.states.get("sensor.test_vehicle_2_vehicle_state") + assert enum_state is not None + assert enum_state.state == STATE_UNKNOWN From 10c6a09dc9b593a6b1af751bc0fd7a2efbf983e4 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:04:31 +0200 Subject: [PATCH 326/707] Migrate UniFi Protect sense sensors to the public API and derive entities from the sensor capability map (#175592) --- .../components/unifiprotect/binary_sensor.py | 54 +++- .../components/unifiprotect/entity.py | 53 +++- .../components/unifiprotect/number.py | 11 +- .../components/unifiprotect/select.py | 6 +- .../components/unifiprotect/sensor.py | 13 + .../unifiprotect/test_binary_sensor.py | 271 ++++++++++++++++++ tests/components/unifiprotect/test_sensor.py | 31 ++ tests/components/unifiprotect/utils.py | 57 +++- 8 files changed, 476 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 86f379fd5521..b4bacac0192d 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -15,14 +15,18 @@ from uiprotect.data import ( SmartDetectObjectType, ) from uiprotect.data.nvr import UOSDisk -from uiprotect.data.public_devices import PublicDeviceModel, PublicSensor +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicSensor, + SensorFeatureCapability, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +41,7 @@ from .entity import ( ProtectIsOnEntity, ProtectNVREntity, async_all_device_entities, + async_remove_unsupported_sense_entities, ) _KEY_DOOR = "door" @@ -49,6 +54,26 @@ def _async_motion_sensor_enabled_public(obj: PublicDeviceModel) -> bool: return sensor.mount_type is not MountType.LEAK and sensor.motion_settings.is_enabled +def _async_contact_sensor_enabled_public(obj: PublicDeviceModel) -> bool: + # Mirrors Sensor.is_contact_sensor_enabled over the public API. + return cast(PublicSensor, obj).is_contact_sensor_enabled + + +def _async_leak_sensor_enabled_public(obj: PublicDeviceModel) -> bool: + # Leak-mounted (UP Sense), or the capability map advertises water_leak with a + # leak channel enabled — the USL family detects leaks without a leak mount. + # Settings alone are not a valid gate: sensors without the capability report + # inert default leak settings. + sensor = cast(PublicSensor, obj) + return sensor.is_leak_sensor_enabled or ( + sensor.supports(SensorFeatureCapability.WATER_LEAK) + and ( + sensor.leak_settings.is_internal_enabled + or sensor.leak_settings.is_external_enabled + ) + ) + + @dataclasses.dataclass(frozen=True, kw_only=True) class ProtectBinaryEntityDescription( ProtectEntityDescription, BinarySensorEntityDescription @@ -315,8 +340,9 @@ MOUNTABLE_SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key=_KEY_DOOR, translation_key="contact", device_class=BinarySensorDeviceClass.DOOR, - ufp_value="is_opened", - ufp_enabled="is_contact_sensor_enabled", + ufp_public_value="is_opened", + ufp_public_enabled_fn=_async_contact_sensor_enabled_public, + ufp_capability=SensorFeatureCapability.OPEN, ), ) @@ -324,8 +350,9 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="leak", device_class=BinarySensorDeviceClass.MOISTURE, - ufp_value="is_leak_detected", - ufp_enabled="is_leak_sensor_enabled", + ufp_public_value="is_leak_detected", + ufp_public_enabled_fn=_async_leak_sensor_enabled_public, + ufp_capability=SensorFeatureCapability.WATER_LEAK, ), ProtectBinaryEntityDescription( key="battery_low", @@ -338,11 +365,13 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.MOTION, ufp_public_value="is_motion_detected", ufp_public_enabled_fn=_async_motion_sensor_enabled_public, + ufp_capability=SensorFeatureCapability.MOTION, ), ProtectBinaryEntityDescription( key="tampering", device_class=BinarySensorDeviceClass.TAMPER, - ufp_value="is_tampering_detected", + ufp_public_value="is_tampering_detected", + ufp_capability=SensorFeatureCapability.TAMPER, ), ProtectBinaryEntityDescription( key="status_light", @@ -356,6 +385,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( translation_key="motion_detection_enabled", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.is_enabled", + ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( @@ -568,8 +598,13 @@ class MountableProtectDeviceBinarySensor(ProtectDeviceBinarySensor): def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) # UP Sense can be any of the 3 contact sensor device classes + mount_type = ( + cast(PublicSensor, public).mount_type + if (public := self._ufp_public_obj) is not None + else self.device.mount_type + ) self._attr_device_class = MOUNT_DEVICE_CLASS_MAP.get( - self.device.mount_type, BinarySensorDeviceClass.DOOR + mount_type, BinarySensorDeviceClass.DOOR ) @@ -733,6 +768,9 @@ async def async_setup_entry( ) -> None: """Set up binary sensors for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities( + hass, Platform.BINARY_SENSOR, data, (*SENSE_SENSORS, *MOUNTABLE_SENSE_SENSORS) + ) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index ebb74046d2cd..4c8c61265d1b 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -20,9 +20,11 @@ from uiprotect.data import ( SmartDetectObjectType, StateType, ) +from uiprotect.data.public_devices import PublicSensor, SensorFeatureCapability -from homeassistant.core import callback -from homeassistant.helpers import device_registry as dr +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -48,6 +50,46 @@ class PermRequired(int, Enum): DELETE = 3 +@callback +def _async_capability_supported( + data: ProtectData, + device: ProtectAdoptableDeviceModel, + description: ProtectEntityDescription, +) -> bool: + """Whether the device advertises the description's required sensor capability.""" + if (capability := description.ufp_capability) is None: + return True + public = data.async_get_public_device(device) + if not isinstance(public, PublicSensor) or not public.has_feature_flags: + return True + return public.supports(capability) + + +@callback +def async_remove_unsupported_sense_entities( + hass: HomeAssistant, + platform: Platform, + data: ProtectData, + descs: Sequence[ProtectEntityDescription], +) -> None: + """Remove registry entries for sense entities the device cannot support. + + Only acts when a public capability map is present (newer firmware); a console + upgrade then drops the never-functional entities created before the map existed. + """ + entity_registry = er.async_get(hass) + for device in data.get_by_types({ModelType.SENSOR}): + for description in descs: + if description.ufp_capability is None or _async_capability_supported( + data, device, description + ): + continue + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, f"{device.mac}_{description.key}" + ): + entity_registry.async_remove(entity_id) + + @callback def _async_device_entities( data: ProtectData, @@ -101,6 +143,9 @@ def _async_device_entities( if not description.has_required(device): continue + if not _async_capability_supported(data, device, description): + continue + entities.append( klass( data, @@ -440,6 +485,10 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046 # Public counterpart of ``ufp_enabled``; a callable because public enablement # is often compound (e.g. mount type plus a settings flag). ufp_public_enabled_fn: Callable[[PublicDeviceModel], bool] | None = None + # Sensor capability required to create the entity, checked against the public + # capability map. Without a capability map (older firmware) every description + # is created, matching the pre-capability behavior. + ufp_capability: SensorFeatureCapability | None = None ufp_perm: PermRequired | None = None # The below are set in __post_init__ diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index e8ee0d0b09d8..5966454c1559 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -7,10 +7,14 @@ import logging from typing import cast, override from uiprotect.data import Camera, Chime, Light, ModelType, ProtectAdoptableDeviceModel -from uiprotect.data.public_devices import PublicDeviceModel, PublicLight +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicLight, + SensorFeatureCapability, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, Platform, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,6 +26,7 @@ from .entity import ( ProtectSettableKeysMixin, T, async_all_device_entities, + async_remove_unsupported_sense_entities, ) from .utils import async_ufp_instance_command @@ -198,6 +203,7 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_step=1, ufp_value="motion_settings.sensitivity", ufp_set_method="set_motion_sensitivity", + ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.WRITE, ), ) @@ -276,6 +282,7 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities(hass, Platform.NUMBER, data, SENSE_NUMBERS) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index fe9fa80f5073..6d1491e41fb2 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -25,10 +25,11 @@ from uiprotect.data import ( Sensor, Viewer, ) +from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.exceptions import GlobalAlarmManagerError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityDescription @@ -44,6 +45,7 @@ from .entity import ( ProtectSettableKeysMixin, T, async_all_device_entities, + async_remove_unsupported_sense_entities, ) from .utils import async_get_light_motion_current, async_ufp_instance_command @@ -316,6 +318,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=MountType, ufp_value="mount_type", ufp_set_method="set_mount_type", + ufp_capability=SensorFeatureCapability.OPEN, ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Sensor]( @@ -355,6 +358,7 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities(hass, Platform.SELECT, data, SENSE_SELECTS) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 127891a2ba18..49d1e8a3fd32 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -16,6 +16,7 @@ from uiprotect.data import ( ProtectDeviceModel, Sensor, ) +from uiprotect.data.public_devices import SensorFeatureCapability from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,6 +29,7 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + Platform, UnitOfDataRate, UnitOfElectricPotential, UnitOfInformation, @@ -48,6 +50,7 @@ from .entity import ( ProtectNVREntity, T, async_all_device_entities, + async_remove_unsupported_sense_entities, ) from .utils import async_get_light_motion_current @@ -310,6 +313,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.light.value", ufp_enabled="is_light_sensor_enabled", + ufp_capability=SensorFeatureCapability.LIGHT, ), ProtectSensorEntityDescription( key="humidity_level", @@ -318,6 +322,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.humidity.value", ufp_enabled="is_humidity_sensor_enabled", + ufp_capability=SensorFeatureCapability.HUMIDITY, ), ProtectSensorEntityDescription( key="temperature_level", @@ -326,18 +331,21 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.temperature.value", ufp_enabled="is_temperature_sensor_enabled", + ufp_capability=SensorFeatureCapability.TEMPERATURE, ), ProtectSensorEntityDescription[Sensor]( key="alarm_sound", translation_key="alarm_sound_detected", ufp_value_fn=_get_alarm_sound, ufp_enabled="is_alarm_sensor_enabled", + ufp_capability=SensorFeatureCapability.SMOKE, ), ProtectSensorEntityDescription( key="door_last_trip_time", translation_key="last_open", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="open_status_changed_at", + ufp_capability=SensorFeatureCapability.OPEN, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( @@ -345,11 +353,13 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="motion_detected_at", + ufp_capability=SensorFeatureCapability.MOTION, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="tampering_last_trip_time", translation_key="last_tampering_detected", + ufp_capability=SensorFeatureCapability.TAMPER, device_class=SensorDeviceClass.TIMESTAMP, ufp_value="tampering_detected_at", entity_registry_enabled_default=False, @@ -360,6 +370,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.sensitivity", + ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( @@ -367,6 +378,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="mount_type", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="mount_type", + ufp_capability=SensorFeatureCapability.OPEN, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( @@ -576,6 +588,7 @@ async def async_setup_entry( ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities(hass, Platform.SENSOR, data, SENSE_SENSORS) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index c05b89b56078..6a82aa938201 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -16,6 +16,7 @@ from uiprotect.data import ( SmartDetectObjectType, ) from uiprotect.data.nvr import EventMetadata +from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.websocket import WebsocketState from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -25,10 +26,12 @@ from homeassistant.components.unifiprotect.binary_sensor import ( LIGHT_SENSORS, MOUNTABLE_SENSE_SENSORS, SENSE_SENSORS, + ProtectBinaryEntityDescription, ) from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, + DOMAIN, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -60,6 +63,9 @@ LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] SENSE_SENSORS_WRITE = SENSE_SENSORS[:3] BATTERY_LOW = next(d for d in SENSE_SENSORS if d.key == "battery_low") SENSE_MOTION = next(d for d in SENSE_SENSORS if d.key == "motion") +SENSE_DOOR = MOUNTABLE_SENSE_SENSORS[0] +SENSE_LEAK = next(d for d in SENSE_SENSORS if d.key == "leak") +SENSE_TAMPERING = next(d for d in SENSE_SENSORS if d.key == "tampering") async def test_binary_sensor_camera_remove( @@ -386,6 +392,271 @@ async def test_binary_sensor_sense_motion_unavailable_without_public( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE +async def test_binary_sensor_sense_door_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The contact sensor reads is_opened from a public WS update.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_DOOR + ) + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_sensor(sensor_all, is_opened=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_binary_sensor_sense_door_unmounted_unavailable( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """A sensor without a contact mount reports the contact sensor unavailable.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_DOOR + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_sensor(sensor_all, mount_type=MountType.NONE) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_binary_sensor_sense_door_device_class_follows_public_mount( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The contact sensor derives its device class from the public mount type.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_DOOR + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value + + public = make_public_sensor(sensor_all, mount_type=MountType.WINDOW) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW.value + + +async def test_binary_sensor_sense_leak_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The leak sensor reads is_leak_detected from a public WS update.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_LEAK + ) + + public = make_public_sensor( + sensor_all, mount_type=MountType.LEAK, is_leak_detected=True + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + ("mount_type", "capabilities", "internal", "external", "expected"), + [ + pytest.param( + MountType.LEAK, None, False, False, STATE_OFF, id="leak-mount-parity" + ), + pytest.param( + MountType.NONE, + {SensorFeatureCapability.WATER_LEAK}, + True, + False, + STATE_OFF, + id="capability-internal", + ), + pytest.param( + MountType.NONE, + {SensorFeatureCapability.WATER_LEAK}, + False, + True, + STATE_OFF, + id="capability-external", + ), + pytest.param( + MountType.NONE, + None, + True, + True, + STATE_UNAVAILABLE, + id="settings-without-capability-map", + ), + pytest.param( + MountType.NONE, + set(), + True, + True, + STATE_UNAVAILABLE, + id="settings-without-capability", + ), + pytest.param( + MountType.NONE, + {SensorFeatureCapability.WATER_LEAK}, + False, + False, + STATE_UNAVAILABLE, + id="capability-without-channel", + ), + ], +) +async def test_binary_sensor_sense_leak_enablement_gate( + hass: HomeAssistant, + ufp: MockUFPFixture, + sensor_all: Sensor, + mount_type: MountType, + capabilities: set[SensorFeatureCapability] | None, + internal: bool, + external: bool, + expected: str, +) -> None: + """The leak gate honors leak mount, water_leak capability and channel settings.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_LEAK + ) + + public = make_public_sensor( + sensor_all, + mount_type=mount_type, + capabilities=capabilities, + leak_internal_enabled=internal, + leak_external_enabled=external, + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == expected + + +async def test_binary_sensor_sense_capability_creation_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """A capability map limits entity creation to the advertised capabilities.""" + setup_public_sensor( + ufp, + capabilities={SensorFeatureCapability.OPEN, SensorFeatureCapability.TAMPER}, + ) + await init_entry(hass, ufp, [sensor_all]) + + for description in (SENSE_DOOR, SENSE_TAMPERING, BATTERY_LOW): + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert entity_registry.async_get(entity_id) is not None + + for description in (SENSE_MOTION, SENSE_LEAK): + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert entity_registry.async_get(entity_id) is None + + +async def test_binary_sensor_sense_capability_registry_cleanup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """A console upgrade removes registry entries for unsupported capabilities.""" + stale = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + f"{sensor_all.mac}_{SENSE_LEAK.key}", + config_entry=ufp.entry, + ) + setup_public_sensor( + ufp, + capabilities={SensorFeatureCapability.OPEN, SensorFeatureCapability.TAMPER}, + ) + await init_entry(hass, ufp, [sensor_all], regenerate_ids=False) + + assert entity_registry.async_get(stale.entity_id) is None + + +async def test_binary_sensor_sense_no_capability_map_creates_all( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """Without a capability map (older firmware) every sense entity is created.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + for description in (SENSE_DOOR, SENSE_TAMPERING, SENSE_MOTION, SENSE_LEAK): + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert entity_registry.async_get(entity_id) is not None + + +async def test_binary_sensor_sense_tampering_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The tampering sensor reads is_tampering_detected from a public WS update.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_TAMPERING + ) + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_sensor(sensor_all, is_tampering_detected=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + "description", + [ + pytest.param(SENSE_DOOR, id="door"), + pytest.param(SENSE_LEAK, id="leak"), + pytest.param(SENSE_TAMPERING, id="tampering"), + ], +) +async def test_binary_sensor_sense_unavailable_without_public( + hass: HomeAssistant, + ufp: MockUFPFixture, + sensor_all: Sensor, + description: ProtectBinaryEntityDescription, +) -> None: + """The migrated sense sensors are unavailable without a public object.""" + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_binary_sensor_battery_low_unavailable_on_public_ws_disconnect( hass: HomeAssistant, ufp: MockUFPFixture, diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index c86f18a3426d..25531ca43c68 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -15,6 +15,7 @@ from uiprotect.data import ( Sensor, ) from uiprotect.data.nvr import EventMetadata +from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -94,6 +95,36 @@ async def test_sensor_sensor_remove( assert_entity_counts(hass, Platform.SENSOR, 22, 14) +async def test_sensor_sense_capability_creation_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """A capability map limits sensor entity creation to the advertised capabilities.""" + setup_public_sensor( + ufp, + capabilities={SensorFeatureCapability.OPEN, SensorFeatureCapability.TAMPER}, + ) + await init_entry(hass, ufp, [sensor_all]) + + for key, created in ( + ("battery_level", True), + ("door_last_trip_time", True), + ("tampering_last_trip_time", True), + ("temperature_level", False), + ("humidity_level", False), + ("light_level", False), + ("alarm_sound", False), + ("motion_last_trip_time", False), + ): + description = next(d for d in SENSE_SENSORS if d.key == key) + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, description + ) + assert (entity_registry.async_get(entity_id) is not None) is created, key + + async def test_sensor_setup_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index db5564c01226..70a713d40076 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -26,9 +26,11 @@ from uiprotect.data.public_devices import ( PublicLight, PublicLightDeviceSettings, PublicSensor, + PublicSensorLeakSettings, PublicSensorMotionSettingsRead, PublicWirelessBatteryStatus, PublicWirelessConnectionState, + SensorFeatureCapability, ) from uiprotect.test_util.anonymize import random_hex from uiprotect.websocket import WebsocketState @@ -234,13 +236,23 @@ def make_public_sensor( is_motion_detected: bool | None = None, motion_enabled: bool | None = None, mount_type: MountType | None = None, + is_opened: bool | None = None, + is_leak_detected: bool | None = None, + is_tampering_detected: bool | None = None, + capabilities: set[SensorFeatureCapability] | None = None, + leak_internal_enabled: bool = False, + leak_external_enabled: bool = False, ) -> Mock: """Build a public-API sensor mirroring a private sensor's migrated fields. - Real ``wireless_connection_state`` / ``motion_settings`` models back the - migrated value paths so a wrong ``ufp_public_value`` path fails the test; - identifiers come from the (synthetic) private sensor fixture, never from real - capture data. Each ``*`` override lets a test diverge from the private value. + Real ``wireless_connection_state`` / ``motion_settings`` / ``leak_settings`` + models back the migrated value paths so a wrong ``ufp_public_value`` path + fails the test; identifiers come from the (synthetic) private sensor fixture, + never from real capture data. Each ``*`` override lets a test diverge from + the private value. The mount-derived enablement properties are computed from + the resolved mount type so a ``mount_type`` override stays consistent. + ``capabilities`` mimics the capability map of newer firmware; ``None`` (the + default) models older firmware without a map, where every entity is created. """ public = Mock(spec=PublicSensor) public.id = sensor.id @@ -248,6 +260,31 @@ def make_public_sensor( public.model = ModelType.SENSOR public.state = DeviceState[sensor.state.name] if state is None else state public.mount_type = sensor.mount_type if mount_type is None else mount_type + public.is_contact_sensor_enabled = public.mount_type in { + MountType.DOOR, + MountType.WINDOW, + MountType.GARAGE, + } + public.is_leak_sensor_enabled = public.mount_type is MountType.LEAK + public.is_opened = sensor.is_opened if is_opened is None else is_opened + public.is_leak_detected = ( + sensor.is_leak_detected if is_leak_detected is None else is_leak_detected + ) + public.is_tampering_detected = ( + sensor.is_tampering_detected + if is_tampering_detected is None + else is_tampering_detected + ) + public.has_feature_flags = capabilities is not None + public.supports = Mock( + side_effect=lambda capability: ( + capabilities is not None and capability in capabilities + ) + ) + public.leak_settings = PublicSensorLeakSettings( + is_internal_enabled=leak_internal_enabled, + is_external_enabled=leak_external_enabled, + ) public.is_motion_detected = ( sensor.is_motion_detected if is_motion_detected is None else is_motion_detected ) @@ -300,12 +337,16 @@ def make_public_light( return public -def setup_public_sensor(ufp: MockUFPFixture) -> None: +def setup_public_sensor( + ufp: MockUFPFixture, + capabilities: set[SensorFeatureCapability] | None = None, +) -> None: """Expose private sensors over the public API via a real ``PublicBootstrap``. Lookups go through the real ``PublicBootstrap.get``; the mirror resolves against the private bootstrap at call time, so it is robust to ``init_entry`` - regenerating device ids. + regenerating device ids. ``capabilities`` is forwarded to the mirror to model + newer firmware with a capability map. """ public_bootstrap = PublicBootstrap() pb = Mock(spec=PublicBootstrap) @@ -320,7 +361,9 @@ def setup_public_sensor(ufp: MockUFPFixture) -> None: model is ModelType.SENSOR and (private := ufp.api.bootstrap.sensors.get(obj_id)) is not None ): - public_bootstrap.sensors[obj_id] = make_public_sensor(private) + public_bootstrap.sensors[obj_id] = make_public_sensor( + private, capabilities=capabilities + ) return public_bootstrap.get(model, obj_id) pb.get = _get From 3c8cea3038096513ae9d7d5624222c3e731f92fe Mon Sep 17 00:00:00 2001 From: "Eduardo J." Date: Thu, 9 Jul 2026 10:07:30 +0200 Subject: [PATCH 327/707] Remove dead assignment in SamsungTVWSBridge._async_get_remote_under_lock (#176045) --- homeassistant/components/samsungtv/bridge.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 09859bee30c2..064f14abc418 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -649,7 +649,6 @@ class SamsungTVWSBridge( ) self._remote = None except ConnectionFailure as err: - error_details = err.args[0] if "ms.channel.timeOut" in (error_details := repr(err)): # The websocket was connected, but the TV is probably asleep LOGGER.debug( From 3d8285dccdb7fe0b47e81a388eb1e5e2bb917a58 Mon Sep 17 00:00:00 2001 From: Andrew Smiley <76449307+Pinball3D@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:07:55 -0400 Subject: [PATCH 328/707] Remove user-configurable polling interval from ScreenLogic integration (#175576) --- .../components/screenlogic/config_flow.py | 51 +---------- .../components/screenlogic/coordinator.py | 7 +- .../components/screenlogic/strings.json | 11 --- tests/components/screenlogic/conftest.py | 5 +- .../snapshots/test_diagnostics.ambr | 1 - .../screenlogic/test_config_flow.py | 85 +------------------ 6 files changed, 8 insertions(+), 152 deletions(-) diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index b9fbd959a965..1eef57773780 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -8,19 +8,12 @@ from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWA from screenlogicpy.requests import login import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -73,15 +66,6 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): self.discovered_gateways: dict[str, dict[str, Any]] = {} self.discovered_ip: str | None = None - @staticmethod - @callback - @override - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> ScreenLogicOptionsFlowHandler: - """Get the options flow for ScreenLogic.""" - return ScreenLogicOptionsFlowHandler() - @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -188,32 +172,3 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={}, ) - - -class ScreenLogicOptionsFlowHandler(OptionsFlow): - """Handles the options for the ScreenLogic integration.""" - - async def async_step_init(self, user_input=None) -> ConfigFlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry( - title=self.config_entry.title, data=user_input - ) - - current_interval = self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - # Polling interval is user-configurable, which is no longer allowed - # pylint: disable-next=home-assistant-config-flow-polling-field - vol.Required( - CONF_SCAN_INTERVAL, - default=current_interval, - ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)) - } - ), - description_placeholders={"gateway_name": self.config_entry.title}, - ) diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 2104f6a9ba22..b77fa0f77989 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -14,7 +14,7 @@ from screenlogicpy.const.common import ( from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -64,15 +64,12 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Initialize the Screenlogic Data Update Coordinator.""" self.gateway = gateway - interval = timedelta( - seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) super().__init__( hass, _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=interval, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), # Debounced option since the device takes # a moment to reflect the knock-on changes request_refresh_debouncer=Debouncer( diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 026597eaff9f..2ed0edd9399f 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -190,17 +190,6 @@ } } }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Seconds between scans" - }, - "description": "Specify settings for {gateway_name}", - "title": "[%key:component::screenlogic::config::step::gateway_entry::title%]" - } - } - }, "services": { "set_color_mode": { "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", diff --git a/tests/components/screenlogic/conftest.py b/tests/components/screenlogic/conftest.py index b9bb22555a17..25727bf7a8c3 100644 --- a/tests/components/screenlogic/conftest.py +++ b/tests/components/screenlogic/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components.screenlogic import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from . import ( MOCK_ADAPTER_IP, @@ -29,9 +29,6 @@ def mock_config_entry() -> MockConfigEntry: CONF_IP_ADDRESS: MOCK_ADAPTER_IP, CONF_PORT: MOCK_ADAPTER_PORT, }, - options={ - CONF_SCAN_INTERVAL: 30, - }, unique_id=MOCK_ADAPTER_MAC, entry_id=MOCK_CONFIG_ENTRY_ID, ) diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index c7db7a339599..77dc3df46bd6 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -13,7 +13,6 @@ 'entry_id': 'screenlogictest', 'minor_version': 1, 'options': dict({ - 'scan_interval': 30, }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index ad8ef125dac2..4c5ac409b534 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -16,12 +16,8 @@ from homeassistant.components.screenlogic.config_flow import ( GATEWAY_MANUAL_ENTRY, GATEWAY_SELECT_KEY, ) -from homeassistant.components.screenlogic.const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - MIN_SCAN_INTERVAL, -) -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.components.screenlogic.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -302,80 +298,3 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} - - -async def test_option_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.screenlogic.async_setup_entry", - return_value=True, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 15}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_SCAN_INTERVAL: 15} - - -async def test_option_flow_defaults(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.screenlogic.async_setup_entry", - return_value=True, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - } - - -async def test_option_flow_input_floor(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.screenlogic.async_setup_entry", - return_value=True, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_SCAN_INTERVAL: 1} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL, - } From 1e5cc1e3071a003d54b50fed0c5a1ead14d8b5cb Mon Sep 17 00:00:00 2001 From: wollew Date: Thu, 9 Jul 2026 10:35:41 +0200 Subject: [PATCH 329/707] Add number entities for velux opening device limitations (#174388) --- homeassistant/components/velux/__init__.py | 4 +- .../components/velux/binary_sensor.py | 7 +- homeassistant/components/velux/coordinator.py | 12 +- homeassistant/components/velux/entity.py | 1 - homeassistant/components/velux/number.py | 200 +++++++++- homeassistant/components/velux/strings.json | 8 + tests/components/velux/conftest.py | 11 + .../velux/snapshots/test_number.ambr | 360 ++++++++++++++++++ tests/components/velux/test_binary_sensor.py | 2 + tests/components/velux/test_number.py | 206 +++++++++- 10 files changed, 791 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index b650e1fe46c4..3fd87f7a52a8 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -2,7 +2,7 @@ import dataclasses -from pyvlx import PyVLX, PyVLXException, Window +from pyvlx import OpeningDevice, PyVLX, PyVLXException from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo limitation_coordinators: dict[int, VeluxLimitationCoordinator] = {} for node in pyvlx.nodes: - if isinstance(node, Window) and node.rain_sensor: + if isinstance(node, OpeningDevice): coordinator = VeluxLimitationCoordinator(hass, entry, node) # do not await coordinator.async_config_entry_first_refresh() here to avoid doing # it for disabled entities, the entities will call it when they are added to hass diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 70517826880c..6216e75aa970 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -60,7 +60,8 @@ class VeluxRainSensor( """Called when the entity is added to Home Assistant.""" await super().async_added_to_hass() # Get initial state as we didn't do it on coordinator initialization to avoid doing it for disabled entities - await self.coordinator.async_request_refresh() + if self.coordinator.data is None: + await self.coordinator.async_request_refresh() @property @override @@ -69,8 +70,8 @@ class VeluxRainSensor( # Velux windows with rain sensors report an opening # limitation when rain is detected. So far we've # seen 89, 91, 93 (most cases) or 100 (Velux GPU). - # It probably makes sense to - # assume that any large enough limitation (we use >=89) means rain is detected. + # It probably makes sense to assume that any large + # enough limitation (we use >=89) means rain is detected. # Documentation on this is non-existent AFAIK. if self.coordinator.data is None: return None diff --git a/homeassistant/components/velux/coordinator.py b/homeassistant/components/velux/coordinator.py index b49b813c1555..7ad9fd2829f0 100644 --- a/homeassistant/components/velux/coordinator.py +++ b/homeassistant/components/velux/coordinator.py @@ -21,6 +21,7 @@ class VeluxLimitationData: """Data for one opening device's limitations.""" limitation_min: Position + limitation_max: Position class VeluxLimitationCoordinator(DataUpdateCoordinator[VeluxLimitationData | None]): @@ -44,9 +45,16 @@ class VeluxLimitationCoordinator(DataUpdateCoordinator[VeluxLimitationData | Non @override async def _async_update_data(self) -> VeluxLimitationData: - """Fetch limitation min data from the device.""" + """Fetch limitation min and max from the device.""" try: min_pos = await self.node.get_limitation_min() + max_pos = await self.node.get_limitation_max() + LOGGER.debug( + "Fetched limitations for %s: pyvlx_min=%s%% pyvlx_max=%s%%", + self.node.name, + min_pos.position_percent, + max_pos.position_percent, + ) except (OSError, PyVLXException) as err: raise UpdateFailed(f"Error fetching limitations: {err}") from err - return VeluxLimitationData(limitation_min=min_pos) + return VeluxLimitationData(limitation_min=min_pos, limitation_max=max_pos) diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 412ae43520a6..0959d22a83a4 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -68,7 +68,6 @@ class VeluxEntity(Entity): def __init__(self, node: Node, config_entry_id: str) -> None: """Initialize the Velux device.""" self.node = node - self._attr_unique_id = velux_unique_id(node, config_entry_id) self._attr_device_info = velux_device_info(node, config_entry_id) diff --git a/homeassistant/components/velux/number.py b/homeassistant/components/velux/number.py index 9e24878027be..e026ef004883 100644 --- a/homeassistant/components/velux/number.py +++ b/homeassistant/components/velux/number.py @@ -1,16 +1,24 @@ -"""Support for Velux exterior heating number entities.""" +"""Support for Velux exterior heating and cover open/closed number entities.""" +from dataclasses import replace from typing import override -from pyvlx import ExteriorHeating, Intensity +from pyvlx import ExteriorHeating, Intensity, OpeningDevice, Position -from homeassistant.components.number import NumberEntity -from homeassistant.const import PERCENTAGE +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfRatio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import VeluxConfigEntry -from .entity import VeluxEntity, wrap_pyvlx_call_exceptions +from .coordinator import VeluxLimitationCoordinator +from .entity import ( + VeluxEntity, + velux_device_info, + velux_unique_id, + wrap_pyvlx_call_exceptions, +) PARALLEL_UPDATES = 1 @@ -22,11 +30,22 @@ async def async_setup_entry( ) -> None: """Set up number entities for the Velux platform.""" pyvlx = config_entry.runtime_data.pyvlx - async_add_entities( + limitation_coordinators = config_entry.runtime_data.limitation_coordinators + entities: list[NumberEntity] = [ VeluxExteriorHeatingNumber(node, config_entry.entry_id) for node in pyvlx.nodes if isinstance(node, ExteriorHeating) - ) + ] + for node in pyvlx.nodes: + if isinstance(node, OpeningDevice): + coordinator = limitation_coordinators[node.node_id] + entities.extend( + [ + VeluxOpenPositionLimitNumber(coordinator, config_entry.entry_id), + VeluxClosedPositionLimitNumber(coordinator, config_entry.entry_id), + ] + ) + async_add_entities(entities) class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity): @@ -56,3 +75,170 @@ class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity): Intensity(intensity_percent=round(value)), wait_for_completion=True, ) + + +class VeluxPositionLimitNumber( + CoordinatorEntity[VeluxLimitationCoordinator], NumberEntity +): + """Shared behavior for Velux limitation number entities. + + Home Assistant expresses cover position as opening percentage, while pyvlx + uses the opposite direction. These entities expose HA-side open/closed + position limits and convert to pyvlx positions only at the API boundary. + """ + + _attr_entity_category = EntityCategory.CONFIG + _attr_entity_registry_enabled_default = False + _attr_mode = NumberMode.BOX + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfRatio.PERCENTAGE + _attr_has_entity_name = True + + _limitation_kind: str + + def __init__( + self, coordinator: VeluxLimitationCoordinator, config_entry_id: str + ) -> None: + """Initialize Velux limitation number.""" + super().__init__(coordinator) + node = coordinator.node + unique_id = velux_unique_id(node, config_entry_id) + self._attr_unique_id = f"{unique_id}_{self._limitation_kind}_limitation" + self._attr_translation_key = f"{self._limitation_kind}_position_limitation" + self._attr_device_info = velux_device_info(node, config_entry_id) + + @override + async def async_added_to_hass(self) -> None: + """Request an immediate refresh when the entity is first added.""" + await super().async_added_to_hass() + # Get initial state as we didn't do it on coordinator initialization to avoid doing it for disabled entities + if self.coordinator.data is None: + await self.coordinator.async_request_refresh() + + @property + @override + def available(self) -> bool: + """Return False until coordinator has successfully populated data. + + The entity is only available once the coordinator has successfully + fetched data at least once. + """ + if self.coordinator.data is None: + return False + return super().available + + @property + @override + def native_value(self) -> float | None: + """Return the current limitation in Home Assistant semantics.""" + if position := self._get_pyvlx_limit(): + return 100 - position.position_percent + return None + + @wrap_pyvlx_call_exceptions + @override + async def async_set_native_value(self, value: float) -> None: + """Set the limitation in Home Assistant semantics.""" + # this will only be called if the entity is available, so coordinator.data is not None + + await self._async_set_pyvlx_limitation( + Position(position_percent=100 - round(value)) + ) + + def _get_pyvlx_limit(self) -> Position | None: + """Get the pyvlx limitation backing this HA-side entity.""" + raise NotImplementedError + + def _updated_pyvlx_limits( + self, updated_position: Position, current_min: Position, current_max: Position + ) -> tuple[Position, Position]: + """Return pyvlx min/max values with this entity's side updated.""" + raise NotImplementedError + + async def _async_set_pyvlx_limitation(self, position: Position) -> None: + """Set pyvlx limitations while preserving the unchanged side.""" + assert self.coordinator.data is not None # checked in async_set_native_value + current_min = self.coordinator.data.limitation_min + current_max = self.coordinator.data.limitation_max + position_min, position_max = self._updated_pyvlx_limits( + position, current_min, current_max + ) + await self.coordinator.node.set_position_limitations( + position_min=position_min, + position_max=position_max, + ) + self.coordinator.async_set_updated_data( + replace( + self.coordinator.data, + limitation_min=position_min, + limitation_max=position_max, + ) + ) + + +class VeluxClosedPositionLimitNumber(VeluxPositionLimitNumber): + """Representation of the closed position limit.""" + + _attr_native_min_value = 0 + _limitation_kind = "closed" + + def _sibling_value(self) -> float | None: + """Return the sibling open limit value, or None if unknown.""" + return ( + 100 - self.coordinator.data.limitation_min.position_percent + if self.coordinator.data + else None + ) + + @property + @override + def native_max_value(self) -> float: + """Return the upper bound: the current open limit (or 100 if unknown).""" + sibling_value = self._sibling_value() + return sibling_value if sibling_value is not None else 100 + + @override + def _get_pyvlx_limit(self) -> Position | None: + """Get the pyvlx max limit backing the HA closed position limit.""" + return self.coordinator.data.limitation_max if self.coordinator.data else None + + @override + def _updated_pyvlx_limits( + self, updated_position: Position, current_min: Position, current_max: Position + ) -> tuple[Position, Position]: + """Update pyvlx max and preserve pyvlx min for HA closed limit changes.""" + return current_min, updated_position + + +class VeluxOpenPositionLimitNumber(VeluxPositionLimitNumber): + """Representation of the open position limit.""" + + _attr_native_max_value = 100 + _limitation_kind = "open" + + def _sibling_value(self) -> float | None: + """Return the sibling close limit value, or None if unknown.""" + return ( + 100 - self.coordinator.data.limitation_max.position_percent + if self.coordinator.data + else None + ) + + @property + @override + def native_min_value(self) -> float: + """Return the lower bound: the current closed limit (or 0 if unknown).""" + sibling_value = self._sibling_value() + return sibling_value if sibling_value is not None else 0 + + @override + def _get_pyvlx_limit(self) -> Position | None: + """Get the pyvlx min limit backing the HA open position limit.""" + return self.coordinator.data.limitation_min if self.coordinator.data else None + + @override + def _updated_pyvlx_limits( + self, updated_position: Position, current_min: Position, current_max: Position + ) -> tuple[Position, Position]: + """Update pyvlx min and preserve pyvlx max for HA open limit changes.""" + return updated_position, current_max diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index f833503aaac9..9a7484f820ee 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -53,6 +53,14 @@ "dual_roller_shutter_upper": { "name": "Upper shutter" } + }, + "number": { + "closed_position_limitation": { + "name": "Closed position limit" + }, + "open_position_limitation": { + "name": "Open position limit" + } } }, "exceptions": { diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index edc8d4a1ec3b..7e0e14156aa9 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -72,6 +72,8 @@ def mock_window() -> AsyncMock: window.rain_sensor = True window.serial_number = "123456789" window.get_limitation_min.return_value = MagicMock(position_percent=0) + window.get_limitation_max.return_value = MagicMock(position_percent=100) + window.set_position_limitations = AsyncMock() window.device_updated_cbs = [] window.is_opening = False window.is_closing = False @@ -98,6 +100,9 @@ def mock_dual_roller_shutter() -> AsyncMock: position_percent=30, closed=False, known=True ) cover.position = MagicMock(position_percent=30, closed=False, known=True) + cover.get_limitation_min.return_value = MagicMock(position_percent=0) + cover.get_limitation_max.return_value = MagicMock(position_percent=100) + cover.set_position_limitations = AsyncMock() cover.pyvlx = MagicMock() return cover @@ -120,6 +125,9 @@ def mock_blind() -> AsyncMock: blind.close_orientation = AsyncMock() blind.stop_orientation = AsyncMock() blind.set_orientation = AsyncMock() + blind.get_limitation_min.return_value = MagicMock(position_percent=0) + blind.get_limitation_max.return_value = MagicMock(position_percent=100) + blind.set_position_limitations = AsyncMock() blind.pyvlx = MagicMock() return blind @@ -191,6 +199,9 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: cover.position_lower_curtain = MagicMock( position_percent=30, closed=False, known=True ) + cover.get_limitation_min.return_value = MagicMock(position_percent=0) + cover.get_limitation_max.return_value = MagicMock(position_percent=100) + cover.set_position_limitations = AsyncMock() cover.pyvlx = MagicMock() return cover diff --git a/tests/components/velux/snapshots/test_number.ambr b/tests/components/velux/snapshots/test_number.ambr index 652cdc8693f4..83963eef53c9 100644 --- a/tests/components/velux/snapshots/test_number.ambr +++ b/tests/components/velux/snapshots/test_number.ambr @@ -1,4 +1,244 @@ # serializer version: 1 +# name: test_number_setup[number.test_blind_closed_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_blind_closed_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Closed position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Closed position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'closed_position_limitation', + 'unique_id': '4711_closed_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_blind_closed_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Blind Closed position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_blind_closed_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number_setup[number.test_blind_open_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_blind_open_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Open position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'open_position_limitation', + 'unique_id': '4711_open_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_blind_open_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Blind Open position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_blind_open_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_closed_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_dual_roller_shutter_closed_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Closed position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Closed position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'closed_position_limitation', + 'unique_id': '987654321_closed_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_closed_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Dual Roller Shutter Closed position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_dual_roller_shutter_closed_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_open_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_dual_roller_shutter_open_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Open position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'open_position_limitation', + 'unique_id': '987654321_open_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_open_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Dual Roller Shutter Open position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_dual_roller_shutter_open_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_number_setup[number.test_exterior_heating-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -59,3 +299,123 @@ 'state': '33', }) # --- +# name: test_number_setup[number.test_window_closed_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_window_closed_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Closed position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Closed position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'closed_position_limitation', + 'unique_id': '123456789_closed_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_window_closed_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Window Closed position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_window_closed_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number_setup[number.test_window_open_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_window_open_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Open position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'open_position_limitation', + 'unique_id': '123456789_open_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_window_open_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Window Open position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_window_open_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index 18657928e492..794e0f4a5246 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -133,6 +133,7 @@ async def test_rain_sensor_unavailability( # Simulate communication error mock_window.get_limitation_min.side_effect = PyVLXException("Connection failed") + mock_window.get_limitation_max.side_effect = PyVLXException("Connection failed") await update_polled_entities(hass, freezer) # Entity should now be unavailable @@ -143,6 +144,7 @@ async def test_rain_sensor_unavailability( # Simulate recovery mock_window.get_limitation_min.side_effect = None mock_window.get_limitation_min.return_value.position_percent = 0 + mock_window.get_limitation_max.side_effect = None await update_polled_entities(hass, freezer) # Entity should be available again state = hass.states.get(test_entity_id) diff --git a/tests/components/velux/test_number.py b/tests/components/velux/test_number.py index 47648ef0c6a7..0dda7c7e8f45 100644 --- a/tests/components/velux/test_number.py +++ b/tests/components/velux/test_number.py @@ -1,9 +1,11 @@ """Test Velux number entities.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest -from pyvlx import Intensity +from pyvlx import Intensity, PyVLXException +from pyvlx.opening_device import Position from homeassistant.components.number import ( ATTR_VALUE, @@ -11,12 +13,12 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, ) from homeassistant.components.velux.const import DOMAIN -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import update_callback_entity +from . import update_callback_entity, update_polled_entities from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform @@ -34,6 +36,7 @@ def get_number_entity_id(mock: AsyncMock) -> str: return f"number.{mock.name.lower().replace(' ', '_')}" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -49,7 +52,7 @@ async def test_number_setup( ) -async def test_number_device_association( +async def test_heating_entity_number_device_association( hass: HomeAssistant, mock_exterior_heating: AsyncMock, entity_registry: er.EntityRegistry, @@ -127,3 +130,196 @@ async def test_set_invalid_value_fails( ) mock_exterior_heating.set_intensity.assert_not_awaited() + + +def closed_limit_entity_id(mock: AsyncMock) -> str: + """Return entity ID of the closed position limit entity.""" + return f"number.{mock.name.lower().replace(' ', '_')}_closed_position_limit" + + +def open_limit_entity_id(mock: AsyncMock) -> str: + """Return entity ID of the open position limit entity.""" + return f"number.{mock.name.lower().replace(' ', '_')}_open_position_limit" + + +async def test_limitation_entity_number_device_association( + hass: HomeAssistant, + mock_window: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Ensure limitation number entity is associated with a device.""" + entity_id = closed_limit_entity_id(mock_window) + + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.device_id is not None + device_entry = device_registry.async_get(entry.device_id) + assert device_entry is not None + assert (DOMAIN, mock_window.serial_number) in device_entry.identifiers + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +async def test_limitation_entities_created( + hass: HomeAssistant, + mock_window: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Open and closed position limit entities are created disabled by default.""" + for get_entity_id in (closed_limit_entity_id, open_limit_entity_id): + entry = entity_registry.async_get(get_entity_id(mock_window)) + assert entry is not None + assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_limitation_entities_enabled_state( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """After enabling, open/closed limit entities reflect HA-side opening semantics.""" + # HA minimum opening comes from pyvlx max, HA maximum opening comes from pyvlx min. + mock_window.get_limitation_min.return_value = MagicMock(position_percent=0) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=100) + await update_polled_entities(hass, freezer) + + assert hass.states.get(closed_limit_entity_id(mock_window)).state == "0" + assert hass.states.get(open_limit_entity_id(mock_window)).state == "100" + + mock_window.get_limitation_min.return_value = MagicMock(position_percent=50) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=30) + await update_polled_entities(hass, freezer) + + assert hass.states.get(closed_limit_entity_id(mock_window)).state == "70" + assert hass.states.get(open_limit_entity_id(mock_window)).state == "50" + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_limitation_entity_bounds_follow_sibling_value( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Reported min/max bounds track the sibling limitation value.""" + mock_window.get_limitation_min.return_value = MagicMock(position_percent=20) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=70) + await update_polled_entities(hass, freezer) + + closed_state = hass.states.get(closed_limit_entity_id(mock_window)) + open_state = hass.states.get(open_limit_entity_id(mock_window)) + + assert closed_state is not None + assert open_state is not None + assert closed_state.attributes["max"] == 80 + assert open_state.attributes["min"] == 30 + + mock_window.get_limitation_min.return_value = MagicMock(position_percent=40) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=90) + await update_polled_entities(hass, freezer) + + closed_state = hass.states.get(closed_limit_entity_id(mock_window)) + open_state = hass.states.get(open_limit_entity_id(mock_window)) + + assert closed_state is not None + assert open_state is not None + assert closed_state.attributes["max"] == 60 + assert open_state.attributes["min"] == 10 + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_min_limitation( + hass: HomeAssistant, + mock_window: AsyncMock, +) -> None: + """Setting HA minimum opening updates pyvlx max and preserves pyvlx min.""" + entity_id = closed_limit_entity_id(mock_window) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 40, "entity_id": entity_id}, + blocking=True, + ) + + mock_window.set_position_limitations.assert_awaited_once_with( + position_min=mock_window.get_limitation_min.return_value, + position_max=Position(position_percent=60), + ) + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_limitation_updates_state_optimistically( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Setting a limitation updates the entity state before the next refresh.""" + mock_window.get_limitation_min.return_value = MagicMock(position_percent=0) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=100) + await update_polled_entities(hass, freezer) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 40, "entity_id": closed_limit_entity_id(mock_window)}, + blocking=True, + ) + + assert hass.states.get(closed_limit_entity_id(mock_window)).state == "40" + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_max_limitation( + hass: HomeAssistant, + mock_window: AsyncMock, +) -> None: + """Setting HA maximum opening updates pyvlx min and preserves pyvlx max.""" + entity_id = open_limit_entity_id(mock_window) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 70, "entity_id": entity_id}, + blocking=True, + ) + + mock_window.set_position_limitations.assert_awaited_once_with( + position_min=Position(position_percent=30), + position_max=mock_window.get_limitation_max.return_value, + ) + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_limitation_unavailable_on_error( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Entities become unavailable when pyvlx raises an exception.""" + mock_window.get_limitation_min.side_effect = PyVLXException("Connection lost") + mock_window.get_limitation_max.side_effect = PyVLXException("Connection lost") + await update_polled_entities(hass, freezer) + + assert ( + hass.states.get(closed_limit_entity_id(mock_window)).state == STATE_UNAVAILABLE + ) + assert hass.states.get(open_limit_entity_id(mock_window)).state == STATE_UNAVAILABLE + + # Recovery + mock_window.get_limitation_min.side_effect = None + mock_window.get_limitation_min.return_value = MagicMock(position_percent=0) + mock_window.get_limitation_max.side_effect = None + mock_window.get_limitation_max.return_value = MagicMock(position_percent=0) + await update_polled_entities(hass, freezer) + + assert ( + hass.states.get(closed_limit_entity_id(mock_window)).state != STATE_UNAVAILABLE + ) + assert hass.states.get(open_limit_entity_id(mock_window)).state != STATE_UNAVAILABLE From bbf038b7bc27988a8451f65f4459cba29c8e9e92 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 9 Jul 2026 10:44:53 +0200 Subject: [PATCH 330/707] Support Z-Wave locks that require coupled users and credentials (#172740) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/zwave_js/lock.py | 10 +- .../components/zwave_js/lock_helpers.py | 290 ++++++++--- homeassistant/components/zwave_js/services.py | 5 + .../components/zwave_js/services.yaml | 16 + .../components/zwave_js/strings.json | 17 +- .../zwave_js/test_credential_services.py | 462 +++++++++++++++++- 6 files changed, 715 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 1e07651bc2bc..a40baddc99db 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -244,9 +244,10 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id) async def async_set_user(self, **kwargs: Any) -> SetUserReturn: - """Create or update an access-control user on the lock.""" + """Create or update an access-control user, optionally with a credential.""" user_type = kwargs.get(const.ATTR_USER_TYPE) credential_rule = kwargs.get(const.ATTR_CREDENTIAL_RULE) + credential_type = kwargs.get(const.ATTR_CREDENTIAL_TYPE) try: return await lock_helpers.async_set_user( self.info.node, @@ -261,6 +262,13 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): else None ), active=kwargs.get(const.ATTR_USER_ACTIVE), + credential_type=( + CREDENTIAL_TYPE_REVERSE_MAP[credential_type] + if credential_type is not None + else None + ), + credential_slot=kwargs.get(const.ATTR_CREDENTIAL_SLOT), + credential_data=kwargs.get(const.ATTR_CREDENTIAL_DATA), ) except BaseZwaveJSServerError as err: raise _credential_service_error("set_user_failed", err) from err diff --git a/homeassistant/components/zwave_js/lock_helpers.py b/homeassistant/components/zwave_js/lock_helpers.py index e4d3c89adeb7..c66500be18f8 100644 --- a/homeassistant/components/zwave_js/lock_helpers.py +++ b/homeassistant/components/zwave_js/lock_helpers.py @@ -16,7 +16,12 @@ from zwave_js_server.const.command_class.access_control import ( UserCredentialType, UserCredentialUserType, ) -from zwave_js_server.model.access_control import SetUserOptions +from zwave_js_server.exceptions import FailedZWaveCommand +from zwave_js_server.model.access_control import ( + AddUserCredential, + SetUserOptions, + UserCredentialCapability, +) from zwave_js_server.model.node import Node from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -191,6 +196,8 @@ class SetUserReturn(TypedDict): """Return type for set_user.""" user_id: int + # None unless a credential was written in the same call. + credential_slot: int | None class SetCredentialReturn(TypedDict): @@ -200,6 +207,121 @@ class SetCredentialReturn(TypedDict): user_id: int +async def _validate_credential_data( + node: Node, + credential_type: UserCredentialType, + credential_data: str, +) -> UserCredentialCapability: + """Validate a credential payload against the device capabilities. + + Returns the capability for the credential type so callers can reuse its + slot information. + """ + cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type)) + cred_caps = await node.access_control.get_credential_capabilities_cached() + type_cap = cred_caps.supported_credential_types.get(credential_type) + if type_cap is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_type_not_supported", + translation_placeholders={"credential_type": cred_type_str}, + ) + + if not ( + type_cap.min_credential_length + <= len(credential_data) + <= type_cap.max_credential_length + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_data_invalid_length", + translation_placeholders={ + "credential_type": cred_type_str, + "min_length": str(type_cap.min_credential_length), + "max_length": str(type_cap.max_credential_length), + }, + ) + if credential_type is UserCredentialType.PIN_CODE and not ( + credential_data.isascii() and credential_data.isdigit() + ): + # str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic), + # which the lock firmware cannot store. Restrict to ASCII 0-9. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_data_pin_not_digits", + ) + + return type_cap + + +async def _async_find_available_user_slot(node: Node) -> int: + """Return the first unused user slot, raising if the lock is full.""" + user_caps = await node.access_control.get_user_capabilities_cached() + users = await node.access_control.get_users_cached() + used_ids = {u.user_id for u in users} + user_id = next( + (i for i in range(1, user_caps.max_users + 1) if i not in used_ids), + None, + ) + if user_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_available_user_slots", + ) + return user_id + + +async def _async_find_available_credential_slot( + node: Node, + credential_type: UserCredentialType, + type_cap: UserCredentialCapability, +) -> int: + """Return the first unused slot for a credential type, raising if full.""" + existing = await node.access_control.get_credentials_by_type_cached(credential_type) + used_slots = {c.slot for c in existing} + slot = next( + ( + s + for s in range(1, type_cap.number_of_credential_slots + 1) + if s not in used_slots + ), + None, + ) + if slot is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_available_credential_slots", + translation_placeholders={ + "credential_type": CREDENTIAL_TYPE_MAP.get( + credential_type, str(credential_type) + ) + }, + ) + return slot + + +async def _async_resolve_credential_slot( + node: Node, + user_id: int, + credential_type: UserCredentialType, + type_cap: UserCredentialCapability, + credential_slot: int | None, +) -> int: + """Resolve the slot a credential should be written to for a user.""" + # An explicit slot is honored as-is. + if credential_slot is not None: + return credential_slot + # When the lock supports independent user and credentials, find the first + # available slot for the credential. + user_caps = await node.access_control.get_user_capabilities_cached() + if user_caps.supports_users_without_credentials: + return await _async_find_available_credential_slot( + node, credential_type, type_cap + ) + # Otherwise the credential must live in the user's own slot. + return user_id + + # --- Business logic functions --- @@ -298,30 +420,17 @@ async def async_set_user( user_type: UserCredentialUserType | None = None, credential_rule: UserCredentialRule | None = None, active: bool | None = None, + credential_slot: int | None = None, + credential_type: UserCredentialType | None = None, + credential_data: str | None = None, ) -> SetUserReturn: - """Create or update an access-control user. Returns the allocated user_id.""" - supported = await node.access_control.is_supported() - if not supported: + """Create or update an access-control user, optionally with a credential.""" + if not await node.access_control.is_supported(): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="access_control_not_supported", ) - # Auto-find first available user slot - if user_id is None: - user_caps = await node.access_control.get_user_capabilities_cached() - users = await node.access_control.get_users_cached() - used_ids = {u.user_id for u in users} - user_id = next( - (i for i in range(1, user_caps.max_users + 1) if i not in used_ids), - None, - ) - if user_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_available_user_slots", - ) - options = SetUserOptions( active=active, user_type=user_type, @@ -329,9 +438,89 @@ async def async_set_user( credential_rule=credential_rule, ) + # No user_id => create a new user via addUser, which handles locks that require + # credentials to be created alongside their user. + if user_id is None: + return await _async_create_user( + node, options, credential_slot, credential_type, credential_data + ) + + # A user_id => update the existing user via setUser, then write any + # credential separately (addUser only creates new users). status = await node.access_control.set_user(user_id, options) _raise_on_set_user_error(status) - return SetUserReturn(user_id=user_id) + + resolved_slot: int | None = None + if credential_type is not None and credential_data is not None: + type_cap = await _validate_credential_data( + node, credential_type, credential_data + ) + resolved_slot = await _async_resolve_credential_slot( + node, user_id, credential_type, type_cap, credential_slot + ) + cred_status = await node.access_control.set_credential( + user_id, credential_type, resolved_slot, credential_data + ) + _raise_on_set_credential_error(cred_status) + + return SetUserReturn(user_id=user_id, credential_slot=resolved_slot) + + +async def _async_create_user( + node: Node, + options: SetUserOptions, + credential_slot: int | None, + credential_type: UserCredentialType | None, + credential_data: str | None, +) -> SetUserReturn: + """Create a new user via addUser, optionally bundling a credential.""" + user_id = await _async_find_available_user_slot(node) + + credential: AddUserCredential | None = None + resolved_slot: int | None = None + if credential_type is not None and credential_data is not None: + type_cap = await _validate_credential_data( + node, credential_type, credential_data + ) + resolved_slot = await _async_resolve_credential_slot( + node, user_id, credential_type, type_cap, credential_slot + ) + credential = AddUserCredential( + credential_type=credential_type, + credential_slot=resolved_slot, + data=credential_data, + ) + else: + user_caps = await node.access_control.get_user_capabilities_cached() + if not user_caps.supports_users_without_credentials: + # On User Code CC a user cannot exist without its code, so zwave-js + # rejects addUser without a credential. Fail early with a clear + # error instead of letting the command fail downstream. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_required", + ) + + result = await node.access_control.add_user(user_id, options, credential) + _raise_on_set_user_error(result.user) + if ( + result.credential is not None + and result.credential is not SetCredentialResult.OK + ): + # addUser creates the user before writing the credential, so on User + # Credential CC a failed credential write leaves a credential-less user + # behind. Roll it back before surfacing the error. (On User Code CC the + # user and credential share a slot, so this cannot occur.) + try: + await node.access_control.delete_user(user_id) + except FailedZWaveCommand: + _LOGGER.warning( + "Could not roll back user %s after its credential failed to add", + user_id, + ) + _raise_on_set_credential_error(result.credential) + + return SetUserReturn(user_id=user_id, credential_slot=resolved_slot) async def async_delete_user(node: Node, user_id: int) -> None: @@ -378,60 +567,12 @@ async def async_set_credential( translation_key="access_control_not_supported", ) - cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type)) - cred_caps = await node.access_control.get_credential_capabilities_cached() - type_cap = cred_caps.supported_credential_types.get(credential_type) - if type_cap is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="credential_type_not_supported", - translation_placeholders={"credential_type": cred_type_str}, - ) - - # Validate credential_data length and format against device capabilities - if not ( - type_cap.min_credential_length - <= len(credential_data) - <= type_cap.max_credential_length - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="credential_data_invalid_length", - translation_placeholders={ - "credential_type": cred_type_str, - "min_length": str(type_cap.min_credential_length), - "max_length": str(type_cap.max_credential_length), - }, - ) - if credential_type is UserCredentialType.PIN_CODE and not ( - credential_data.isascii() and credential_data.isdigit() - ): - # str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic), - # which the lock firmware cannot store. Restrict to ASCII 0-9. - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="credential_data_pin_not_digits", - ) + type_cap = await _validate_credential_data(node, credential_type, credential_data) if credential_slot is None: - existing = await node.access_control.get_credentials_by_type_cached( - credential_type + credential_slot = await _async_find_available_credential_slot( + node, credential_type, type_cap ) - used_slots = {c.slot for c in existing} - credential_slot = next( - ( - s - for s in range(1, type_cap.number_of_credential_slots + 1) - if s not in used_slots - ), - None, - ) - if credential_slot is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_available_credential_slots", - translation_placeholders={"credential_type": cred_type_str}, - ) status = await node.access_control.set_credential( user_id, credential_type, credential_slot, credential_data @@ -450,7 +591,12 @@ async def async_delete_credential( credential_type: UserCredentialType, credential_slot: int, ) -> None: - """Delete a single credential.""" + """Delete a single credential. + + On User Code CC the credential shares its user's slot, so zwave-js clears + the whole slot and deletes the user along with the credential; no special + handling is needed here. + """ if not await node.access_control.is_supported(): raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 92f45f90ab71..80aba468d7ad 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -92,6 +92,11 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: CREDENTIAL_RULE_REVERSE_MAP.keys() ), vol.Optional(const.ATTR_USER_ACTIVE): cv.boolean, + vol.Inclusive(const.ATTR_CREDENTIAL_TYPE, "credential"): vol.In( + const.WRITABLE_CREDENTIAL_TYPES + ), + vol.Optional(const.ATTR_CREDENTIAL_SLOT): uint16_id, + vol.Inclusive(const.ATTR_CREDENTIAL_DATA, "credential"): cv.string, }, func="async_set_user", supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index dc1bbbdc1aef..4cb2b6a214b8 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -129,6 +129,22 @@ set_user: active: selector: boolean: + credential_type: + selector: + select: + options: + - pin_code + - password + credential_slot: + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + credential_data: + selector: + text: delete_user: target: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 95536733c869..48ef507a46a9 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -322,6 +322,9 @@ "credential_rejected_wrong_uuid": { "message": "The device rejected the credential because the user unique identifier does not match." }, + "credential_required": { + "message": "This device requires a credential when adding a user." + }, "credential_type_not_supported": { "message": "Credential type {credential_type} is not supported on this device" }, @@ -800,16 +803,28 @@ "name": "Set lock user code" }, "set_user": { - "description": "Creates or updates an access-control user on a lock entity. Returns the allocated user_id for each targeted lock entity.", + "description": "Creates or updates an access-control user on a lock entity. When no user_id is given, a new user is created. A credential (PIN code or password) can be written in the same call. Returns the allocated user_id, plus the credential_slot when a credential was written, for each targeted lock entity.", "fields": { "active": { "description": "Whether the user is active.", "name": "Active" }, + "credential_data": { + "description": "The credential data (e.g. PIN digits or password string).", + "name": "Credential data" + }, "credential_rule": { "description": "Credential rule for the user (single, dual, or triple).", "name": "Credential rule" }, + "credential_slot": { + "description": "Credential slot index. If not specified, the first available slot is used.", + "name": "Credential slot" + }, + "credential_type": { + "description": "The type of credential (pin_code or password).", + "name": "Credential type" + }, "user_id": { "description": "User slot index. If not specified, the first available slot is used.", "name": "User index" diff --git a/tests/components/zwave_js/test_credential_services.py b/tests/components/zwave_js/test_credential_services.py index 2b9903d19e92..61742b66403e 100644 --- a/tests/components/zwave_js/test_credential_services.py +++ b/tests/components/zwave_js/test_credential_services.py @@ -13,7 +13,12 @@ from zwave_js_server.const.command_class.access_control import ( UserCredentialUserType, ) from zwave_js_server.exceptions import FailedZWaveCommand -from zwave_js_server.model.access_control import AccessControlAPI, SetUserOptions +from zwave_js_server.model.access_control import ( + AccessControlAPI, + AddUserCredential, + AddUserResult, + SetUserOptions, +) from zwave_js_server.model.node import Node from homeassistant.components.zwave_js.const import DOMAIN @@ -26,7 +31,9 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry -def _mock_access_control(node: Node) -> MagicMock: +def _mock_access_control( + node: Node, *, supports_users_without_credentials: bool = True +) -> MagicMock: """Inject a mock AccessControlAPI into the node's endpoint 0.""" api = create_autospec(AccessControlAPI, instance=True) api.is_supported.return_value = True @@ -36,6 +43,7 @@ def _mock_access_control(node: Node) -> MagicMock: user_caps.supported_user_types = [UserCredentialUserType.GENERAL] user_caps.max_user_name_length = 20 user_caps.supported_credential_rules = [] + user_caps.supports_users_without_credentials = supports_users_without_credentials api.get_user_capabilities_cached.return_value = user_caps pin_cap = MagicMock() @@ -62,6 +70,9 @@ def _mock_access_control(node: Node) -> MagicMock: api.get_users_cached.return_value = [] api.get_user_cached.return_value = None api.set_user.return_value = SetUserResult.OK + api.add_user.return_value = AddUserResult( + user=SetUserResult.OK, credential=SetCredentialResult.OK + ) api.delete_user.return_value = SetUserResult.OK api.delete_all_users.return_value = SetUserResult.OK @@ -101,7 +112,7 @@ def _lock_entity_id( raise AssertionError(f"No lock entity found for device {device_id}") -async def test_set_user_auto_find( +async def test_set_user_new_user_auto_find( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, @@ -109,8 +120,9 @@ async def test_set_user_auto_find( lock_schlage_be469: Node, integration: MockConfigEntry, ) -> None: - """Test set_user with auto-find user slot returns allocated user_id.""" + """Without a user_id, set_user creates a new user via addUser.""" api = _mock_access_control(lock_schlage_be469) + api.add_user.return_value = AddUserResult(user=SetUserResult.OK) entity_id = _lock_entity_id( entity_registry, device_registry, client, lock_schlage_be469 ) @@ -128,7 +140,9 @@ async def test_set_user_auto_find( return_response=True, ) - api.set_user.assert_called_once_with( + # No user_id => addUser, auto-allocating the first free user slot. + api.set_user.assert_not_called() + api.add_user.assert_called_once_with( 1, SetUserOptions( active=True, @@ -136,11 +150,12 @@ async def test_set_user_auto_find( user_name="Alice", credential_rule=None, ), + None, ) - assert result == {entity_id: {"user_id": 1}} + assert result == {entity_id: {"user_id": 1, "credential_slot": None}} -async def test_set_user_explicit_index( +async def test_set_user_existing_user( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, @@ -148,7 +163,7 @@ async def test_set_user_explicit_index( lock_schlage_be469: Node, integration: MockConfigEntry, ) -> None: - """Test set_user with explicit user index echoes it back.""" + """With a user_id, set_user updates the existing user via setUser.""" api = _mock_access_control(lock_schlage_be469) entity_id = _lock_entity_id( entity_registry, device_registry, client, lock_schlage_be469 @@ -166,6 +181,8 @@ async def test_set_user_explicit_index( return_response=True, ) + # A user_id => setUser, not the addUser create path. + api.add_user.assert_not_called() api.set_user.assert_called_once_with( 5, SetUserOptions( @@ -175,7 +192,7 @@ async def test_set_user_explicit_index( credential_rule=None, ), ) - assert result == {entity_id: {"user_id": 5}} + assert result == {entity_id: {"user_id": 5, "credential_slot": None}} async def test_set_user_no_slots( @@ -212,11 +229,377 @@ async def test_set_user_no_slots( ) assert exc.value.translation_key == "no_available_user_slots" - # Here, we fail trying to find a free user slot, meaning - # before we even call set_user. + # We fail while searching for a free user slot, before issuing addUser. + api.add_user.assert_not_called() api.set_user.assert_not_called() +async def test_set_user_new_user_with_credential( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """A new user and its credential are written in one addUser call.""" + api = _mock_access_control(lock_schlage_be469) + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_name": "Alice", + "user_type": "general", + "active": True, + "credential_type": "pin_code", + "credential_data": "1234", + }, + blocking=True, + return_response=True, + ) + + # The combined addUser API is used, not the two-call setUser/setCredential path + api.set_user.assert_not_called() + api.set_credential.assert_not_called() + api.add_user.assert_called_once_with( + 1, + SetUserOptions( + active=True, + user_type=UserCredentialUserType.GENERAL, + user_name="Alice", + credential_rule=None, + ), + AddUserCredential( + credential_type=UserCredentialType.PIN_CODE, + credential_slot=1, + data="1234", + ), + ) + assert result == {entity_id: {"user_id": 1, "credential_slot": 1}} + + +async def test_set_user_rolls_back_on_credential_failure( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """If the credential write fails, the just-created user is rolled back.""" + api = _mock_access_control(lock_schlage_be469) + # User created OK, but the credential was rejected as a duplicate. + api.add_user.return_value = AddUserResult( + user=SetUserResult.OK, + credential=SetCredentialResult.ERROR_DUPLICATE_CREDENTIAL, + ) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "user_name": "Alice", + "credential_type": "pin_code", + "credential_data": "1234", + }, + blocking=True, + return_response=True, + ) + + # The credential error is surfaced, and the auto-allocated user (slot 1) is + # deleted so the lock is not left with a credential-less user. + assert exc.value.translation_key == "credential_rejected_duplicate" + api.delete_user.assert_called_once_with(1) + + +async def test_set_user_rollback_failure_still_raises_credential_error( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """A failed rollback is swallowed; the credential error is still surfaced.""" + api = _mock_access_control(lock_schlage_be469) + api.add_user.return_value = AddUserResult( + user=SetUserResult.OK, + credential=SetCredentialResult.ERROR_DUPLICATE_CREDENTIAL, + ) + api.delete_user.side_effect = FailedZWaveCommand("boom", 1, "boom") + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "user_name": "Alice", + "credential_type": "pin_code", + "credential_data": "1234", + }, + blocking=True, + return_response=True, + ) + + assert exc.value.translation_key == "credential_rejected_duplicate" + api.delete_user.assert_called_once_with(1) + + +@pytest.mark.parametrize( + ("supports_users_without_credentials", "expected_credential_slot"), + [ + # User Credential CC: the credential gets its own slot, auto-allocated + # to the first free one (1) independently of the user's slot (3). + pytest.param(True, 1, id="user_credential_cc_independent_slot"), + # User Code CC: the credential shares the user's slot, so it must match + # the auto-allocated user ID (3). + pytest.param(False, 3, id="user_code_cc_shared_slot"), + ], +) +async def test_set_user_new_user_credential_slot_allocation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + supports_users_without_credentials: bool, + expected_credential_slot: int, +) -> None: + """A new user's credential slot resolution differs by command class.""" + api = _mock_access_control( + lock_schlage_be469, + supports_users_without_credentials=supports_users_without_credentials, + ) + # Occupy user slots 1 and 2 so the new user gets slot 3, while all credential + # slots stay free (first free is 1). This makes the user's slot and the first + # free credential slot diverge so the two command classes pick different ones. + user1 = MagicMock() + user1.user_id = 1 + user2 = MagicMock() + user2.user_id = 2 + api.get_users_cached.return_value = [user1, user2] + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_name": "Bob", + "credential_type": "pin_code", + "credential_data": "5678", + }, + blocking=True, + return_response=True, + ) + + api.set_user.assert_not_called() + api.set_credential.assert_not_called() + api.add_user.assert_called_once_with( + 3, + SetUserOptions( + active=None, + user_type=None, + user_name="Bob", + credential_rule=None, + ), + AddUserCredential( + credential_type=UserCredentialType.PIN_CODE, + credential_slot=expected_credential_slot, + data="5678", + ), + ) + assert result == { + entity_id: {"user_id": 3, "credential_slot": expected_credential_slot} + } + + +@pytest.mark.parametrize( + ("supports_users_without_credentials", "expected_credential_slot"), + [ + # User Credential CC: the credential is auto-allocated its own free slot. + pytest.param(True, 1, id="user_credential_cc_independent_slot"), + # User Code CC: the credential shares the existing user's slot (5). + pytest.param(False, 5, id="user_code_cc_shared_slot"), + ], +) +async def test_set_user_existing_user_with_credential( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + supports_users_without_credentials: bool, + expected_credential_slot: int, +) -> None: + """Updating an existing user with a credential uses setUser + setCredential.""" + api = _mock_access_control( + lock_schlage_be469, + supports_users_without_credentials=supports_users_without_credentials, + ) + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_id": 5, + "user_name": "Bob", + "credential_type": "pin_code", + "credential_data": "5678", + }, + blocking=True, + return_response=True, + ) + + # A user_id => setUser, then the credential is written separately. + api.add_user.assert_not_called() + api.set_user.assert_called_once_with( + 5, + SetUserOptions( + active=None, + user_type=None, + user_name="Bob", + credential_rule=None, + ), + ) + api.set_credential.assert_called_once_with( + 5, + UserCredentialType.PIN_CODE, + expected_credential_slot, + "5678", + ) + assert result == { + entity_id: {"user_id": 5, "credential_slot": expected_credential_slot} + } + + +async def test_set_user_existing_user_explicit_credential_slot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """An explicit credential_slot is honored on the existing-user path.""" + api = _mock_access_control(lock_schlage_be469) + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_id": 5, + "credential_type": "pin_code", + "credential_data": "5678", + "credential_slot": 7, + }, + blocking=True, + return_response=True, + ) + + api.add_user.assert_not_called() + api.set_user.assert_called_once_with( + 5, + SetUserOptions( + active=None, + user_type=None, + user_name=None, + credential_rule=None, + ), + ) + api.set_credential.assert_called_once_with( + 5, UserCredentialType.PIN_CODE, 7, "5678" + ) + assert result == {entity_id: {"user_id": 5, "credential_slot": 7}} + + +async def test_set_user_invalid_pin( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """set_user validates the credential before creating the user.""" + api = _mock_access_control(lock_schlage_be469) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "credential_type": "pin_code", + "credential_data": "abcd", + }, + blocking=True, + return_response=True, + ) + + assert exc.value.translation_key == "credential_data_pin_not_digits" + api.add_user.assert_not_called() + + +async def test_set_user_requires_credential_on_user_code_cc( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """User Code CC locks reject creating a user without a credential.""" + api = _mock_access_control( + lock_schlage_be469, supports_users_without_credentials=False + ) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "user_name": "Alice", + }, + blocking=True, + return_response=True, + ) + + # Users and codes share a slot here, so a user cannot be created without a + # credential; the helper rejects it before reaching the device. + assert exc.value.translation_key == "credential_required" + api.add_user.assert_not_called() + + async def test_delete_user( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -1354,3 +1737,60 @@ async def test_delete_all_credentials_single_failure_unwrapped( ) assert exc.value.translation_key == "credential_rejected_unknown" + + +@pytest.mark.parametrize( + ("service", "service_data", "returns_response"), + [ + pytest.param("set_user", {}, True, id="set_user"), + pytest.param("delete_user", {"user_id": 1}, False, id="delete_user"), + pytest.param("delete_all_users", {}, False, id="delete_all_users"), + pytest.param("get_users", {}, True, id="get_users"), + pytest.param( + "set_credential", + {"user_id": 1, "credential_type": "pin_code", "credential_data": "1234"}, + True, + id="set_credential", + ), + pytest.param( + "delete_credential", + {"user_id": 1, "credential_type": "pin_code", "credential_slot": 1}, + False, + id="delete_credential", + ), + pytest.param( + "delete_all_credentials", {"user_id": 1}, False, id="delete_all_credentials" + ), + ], +) +async def test_service_access_control_not_supported( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + service: str, + service_data: dict[str, int | str], + returns_response: bool, +) -> None: + """Every user/credential service fails fast when access control is unsupported.""" + api = _mock_access_control(lock_schlage_be469) + api.is_supported.return_value = False + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + return_response=returns_response, + ) + + assert exc.value.translation_key == "access_control_not_supported" + # The guard runs before anything else, so no capability query is issued. + api.is_supported.assert_called_once_with() + api.get_user_capabilities_cached.assert_not_called() From c6939ae93db967d07685e2c9b443967ec3763532 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Jul 2026 11:00:59 +0200 Subject: [PATCH 331/707] Fix trusted proxies type when HTTP config is loaded from storage (#176068) Co-authored-by: Claude Fable 5 Co-authored-by: Robert Resch Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/http/__init__.py | 6 +++++- homeassistant/components/http/config.py | 4 ++-- tests/components/http/test_init.py | 25 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 9051f423fddd..4474afcb0cd5 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -207,7 +207,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS] - trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or [] + # The loaded config stores trusted proxies as strings (JSON-serializable); + # the forwarded middleware needs IPv4Network/IPv6Network objects. + trusted_proxies = [ + ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] + ] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] ssl_profile = conf[CONF_SSL_PROFILE] diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py index ad5954743e29..7564780ba643 100644 --- a/homeassistant/components/http/config.py +++ b/homeassistant/components/http/config.py @@ -2,7 +2,7 @@ import asyncio from datetime import datetime, timedelta -from ipaddress import IPv4Network, IPv6Network, ip_network +from ipaddress import ip_network import logging import os from typing import Any, Final, TypedDict, cast, override @@ -87,7 +87,7 @@ class ConfData(TypedDict, total=False): ssl_key: str cors_allowed_origins: list[str] use_x_forwarded_for: bool - trusted_proxies: list[IPv4Network | IPv6Network] + trusted_proxies: list[str] login_attempts_threshold: int ip_ban_enabled: bool ssl_profile: str diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 86d06218b600..a765f3a322b9 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -172,6 +172,31 @@ async def test_proxy_config(hass: HomeAssistant) -> None: ) +async def test_proxy_config_forwarded_request( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test a request with X-Forwarded-For from a trusted proxy is accepted. + + The config store persists trusted proxies as strings; this exercises the + forwarded middleware with a config loaded from the store to guard against + passing the strings through instead of IP network objects. + """ + hass_storage[DOMAIN] = _stable_http_storage( + { + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + } + ) + assert await async_setup_component(hass, "api", {}) + client = await hass_client() + + resp = await client.get("/api/", headers={"X-Forwarded-For": "203.0.113.5"}) + + assert resp.status == HTTPStatus.OK + + async def test_proxy_config_only_use_xff(hass: HomeAssistant) -> None: """Test use_x_forwarded_for must config together with trusted_proxies.""" assert ( From 8109722e75814457958b42f1b5dae5c9808173c0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:07:02 -0400 Subject: [PATCH 332/707] Update infrared-protocols to 6.6.1 (#176052) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 686c88c5cff6..f22bfd5f738d 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==6.6.0"] + "requirements": ["infrared-protocols==6.6.1"] } diff --git a/requirements.txt b/requirements.txt index f71bd2c24e23..af7dbd71e8ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==6.6.0 +infrared-protocols==6.6.1 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index ef39571dbd6b..5dbaa39ef8b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1362,7 +1362,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==6.6.0 +infrared-protocols==6.6.1 # homeassistant.components.inkbird inkbird-ble==1.4.4 From fea4cfaa22c7acf070d49328cbff4b8bad632dc4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Jul 2026 11:23:42 +0200 Subject: [PATCH 333/707] Convert tracing context managers to classes to cut per-step overhead (#173687) Co-authored-by: Paulus Schoutsen Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/condition.py | 56 +++++++---- homeassistant/helpers/script.py | 154 ++++++++++++++++------------- homeassistant/helpers/trace.py | 26 +++-- 3 files changed, 142 insertions(+), 94 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 2ff29fdb9898..971cb9a961e7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,8 +3,7 @@ import abc import asyncio from collections import deque -from collections.abc import Callable, Container, Coroutine, Generator, Iterable, Mapping -from contextlib import contextmanager +from collections.abc import Callable, Container, Coroutine, Iterable, Mapping from dataclasses import dataclass from datetime import datetime, time as dt_time, timedelta import functools as ft @@ -1233,25 +1232,42 @@ def condition_trace_update_result(**kwargs: Any) -> None: node.update_result(**kwargs) -@contextmanager -def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement]: +class trace_condition: """Trace condition evaluation.""" - should_pop = True - trace_element = trace_stack_top(trace_stack_cv) - if trace_element and trace_element.reuse_by_child: - should_pop = False - trace_element.reuse_by_child = False - else: - trace_element = condition_trace_append(variables, trace_path_get()) - trace_stack_push(trace_stack_cv, trace_element) - try: - yield trace_element - except Exception as ex: - trace_element.set_error(ex) - raise - finally: - if should_pop: - trace_stack_pop(trace_stack_cv) + + __slots__ = ("_should_pop", "_trace_element", "_variables") + + _should_pop: bool + _trace_element: TraceElement + + def __init__(self, variables: TemplateVarsType) -> None: + """Store the variables for the trace element.""" + self._variables = variables + + def __enter__(self) -> TraceElement: + """Start tracing the condition evaluation.""" + should_pop = True + trace_element = trace_stack_top(trace_stack_cv) + if trace_element and trace_element.reuse_by_child: + should_pop = False + trace_element.reuse_by_child = False + else: + trace_element = condition_trace_append(self._variables, trace_path_get()) + trace_stack_push(trace_stack_cv, trace_element) + self._should_pop = should_pop + self._trace_element = trace_element + return trace_element + + def __exit__( + self, exc_type: object, exc_val: BaseException | None, exc_tb: object + ) -> None: + """Finish tracing the condition evaluation.""" + try: + if exc_val is not None and isinstance(exc_val, Exception): + self._trace_element.set_error(exc_val) + finally: + if self._should_pop: + trace_stack_pop(trace_stack_cv) @overload diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 0b98324bf2b3..39355b44b838 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,8 +1,7 @@ """Helpers to execute scripts.""" import asyncio -from collections.abc import AsyncGenerator, Callable, Mapping, Sequence -from contextlib import asynccontextmanager +from collections.abc import Callable, Mapping, Sequence from contextvars import ContextVar from copy import copy from dataclasses import dataclass @@ -187,79 +186,102 @@ def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: return trace_element -@asynccontextmanager -async def trace_action( - hass: HomeAssistant, - script_run: _ScriptRun, - stop: asyncio.Future[None], - variables: TemplateVarsType, -) -> AsyncGenerator[TraceElement]: +class trace_action: """Trace action execution.""" - path = trace_path_get() - trace_element = action_trace_append(variables, path) - trace_stack_push(trace_stack_cv, trace_element) - trace_id = trace_id_get() - if trace_id: - key = trace_id[0] - run_id = trace_id[1] - breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] - if key in breakpoints and ( - ( - run_id in breakpoints[key] - and ( - path in breakpoints[key][run_id] - or NODE_ANY in breakpoints[key][run_id] + __slots__ = ("_hass", "_stop", "_trace_element", "_variables") + + _trace_element: TraceElement + + def __init__( + self, + hass: HomeAssistant, + script_run: _ScriptRun, + stop: asyncio.Future[None], + variables: TemplateVarsType, + ) -> None: + """Store the data needed to trace the action.""" + self._hass = hass + self._stop = stop + self._variables = variables + + async def __aenter__(self) -> TraceElement: + """Start tracing the action, handling any configured breakpoint.""" + hass = self._hass + stop = self._stop + path = trace_path_get() + trace_element = action_trace_append(self._variables, path) + self._trace_element = trace_element + trace_stack_push(trace_stack_cv, trace_element) + + trace_id = trace_id_get() + if trace_id: + key = trace_id[0] + run_id = trace_id[1] + breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] + if key in breakpoints and ( + ( + run_id in breakpoints[key] + and ( + path in breakpoints[key][run_id] + or NODE_ANY in breakpoints[key][run_id] + ) ) - ) - or ( - RUN_ID_ANY in breakpoints[key] - and ( - path in breakpoints[key][RUN_ID_ANY] - or NODE_ANY in breakpoints[key][RUN_ID_ANY] + or ( + RUN_ID_ANY in breakpoints[key] + and ( + path in breakpoints[key][RUN_ID_ANY] + or NODE_ANY in breakpoints[key][RUN_ID_ANY] + ) + ) + ): + async_dispatcher_send_internal( + hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path ) - ) - ): - async_dispatcher_send_internal( - hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path - ) - done = hass.loop.create_future() + done = hass.loop.create_future() - @callback - def async_continue_stop( - command: Literal["continue", "stop"] | None = None, - ) -> None: - if command == "stop": - _set_result_unless_done(stop) - _set_result_unless_done(done) + @callback + def async_continue_stop( + command: Literal["continue", "stop"] | None = None, + ) -> None: + if command == "stop": + _set_result_unless_done(stop) + _set_result_unless_done(done) - signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) - remove_signal1 = async_dispatcher_connect(hass, signal, async_continue_stop) - remove_signal2 = async_dispatcher_connect( - hass, SCRIPT_DEBUG_CONTINUE_ALL, async_continue_stop - ) + signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) + remove_signal1 = async_dispatcher_connect( + hass, signal, async_continue_stop + ) + remove_signal2 = async_dispatcher_connect( + hass, SCRIPT_DEBUG_CONTINUE_ALL, async_continue_stop + ) - await asyncio.wait([stop, done], return_when=asyncio.FIRST_COMPLETED) - remove_signal1() - remove_signal2() + await asyncio.wait([stop, done], return_when=asyncio.FIRST_COMPLETED) + remove_signal1() + remove_signal2() - try: - yield trace_element - except _AbortScript as ex: - trace_element.set_error(ex.__cause__ or ex) - raise - except _ConditionFail: - # Clear errors which may have been set when evaluating the condition - trace_element.set_error(None) - raise - except _StopScript: - raise - except Exception as ex: - trace_element.set_error(ex) - raise - finally: - trace_stack_pop(trace_stack_cv) + return trace_element + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + """Finish tracing the action, recording any error.""" + try: + if isinstance(exc_val, _AbortScript): + self._trace_element.set_error(exc_val.__cause__ or exc_val) + elif isinstance(exc_val, _ConditionFail): + # Clear errors which may have been set when evaluating the condition + self._trace_element.set_error(None) + elif isinstance(exc_val, _StopScript): + pass + elif isinstance(exc_val, Exception): + self._trace_element.set_error(exc_val) + finally: + trace_stack_pop(trace_stack_cv) def make_script_schema( diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 2c2110af8dfc..84836af8c9d3 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -305,17 +305,27 @@ def script_execution_get() -> str | None: return data.script_execution -@contextmanager -def trace_path(suffix: str | list[str]) -> Generator[None]: +class trace_path: """Go deeper in the config tree. - Can not be used as a decorator on couroutine functions. + Can not be used as a decorator. """ - count = trace_path_push(suffix) - try: - yield - finally: - trace_path_pop(count) + + __slots__ = ("_count", "_suffix") + + _count: int + + def __init__(self, suffix: str | list[str]) -> None: + """Store the path suffix to push on enter.""" + self._suffix = suffix + + def __enter__(self) -> None: + """Go deeper in the config tree.""" + self._count = trace_path_push(self._suffix) + + def __exit__(self, *exc: object) -> None: + """Go back up in the config tree.""" + trace_path_pop(self._count) def async_trace_path[*_Ts]( From 8db3cb5ba5a3d88b7137bed8268c5d34b92c5597 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 9 Jul 2026 11:24:55 +0200 Subject: [PATCH 334/707] Fix max color temperature in Elgato (#176072) --- homeassistant/components/elgato/light.py | 2 +- tests/components/elgato/snapshots/test_light.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index e85ff73a8751..fb54f916114d 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -35,7 +35,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): _attr_name = None _attr_min_color_temp_kelvin = 2900 # 344 Mireds - _attr_max_color_temp_kelvin = 7000 # 143 Mireds + _attr_max_color_temp_kelvin = 6993 # 143 Mireds def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None: """Initialize Elgato Light.""" diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index fdc397c8436d..18f8e78da416 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -10,7 +10,7 @@ 27.316, 47.743, ), - : 7000, + : 6993, : 2900, : tuple( 255, @@ -41,7 +41,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : 7000, + : 6993, : 2900, : list([ , From dfb8e6a658bd3e6e60d88dbd7df93f44ad8d1d64 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 9 Jul 2026 11:34:02 +0200 Subject: [PATCH 335/707] Make Elgato key light snappier (#176071) --- homeassistant/components/elgato/light.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index fb54f916114d..3d5ba506df96 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -100,7 +100,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" await self.coordinator.client.light(on=False) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() @elgato_exception_handler @override @@ -143,7 +143,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): saturation=saturation, temperature=temperature, ) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() @elgato_exception_handler async def async_identify(self) -> None: From c90358adcf8279174f7edc209f0b30a95762417c Mon Sep 17 00:00:00 2001 From: Imou-OpenPlatform Date: Thu, 9 Jul 2026 17:36:43 +0800 Subject: [PATCH 336/707] Bump pyimouapi to 1.3.0 (#176070) --- homeassistant/components/imou/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json index 7d25d81e79ed..0ef2893fc15a 100644 --- a/homeassistant/components/imou/manifest.json +++ b/homeassistant/components/imou/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["pyimouapi==1.2.8"] + "requirements": ["pyimouapi==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5dbaa39ef8b6..fe5d9c639e3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2245,7 +2245,7 @@ pyialarm==2.2.0 pyicloud==2.6.5 # homeassistant.components.imou -pyimouapi==1.2.8 +pyimouapi==1.3.0 # homeassistant.components.insteon pyinsteon==1.6.4 From 52ce20bac74585451ab5fbf22699e7aac363af38 Mon Sep 17 00:00:00 2001 From: Stefan Lettmayer Date: Thu, 9 Jul 2026 11:43:01 +0200 Subject: [PATCH 337/707] Skip whitespace-only content when converting Anthropic chat log (#175733) Co-authored-by: Claude Fable 5 --- homeassistant/components/anthropic/entity.py | 25 +++- tests/components/anthropic/test_ai_task.py | 48 ++++++++ .../components/anthropic/test_conversation.py | 107 +++++++++++++++++- 3 files changed, 175 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 4a4ffb86554d..df503af7b46e 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -233,8 +233,9 @@ def _convert_content( # noqa: C901 """Transform HA chat_log content into Anthropic API format.""" messages: list[MessageParam] = [] container_id: str | None = None + contents = list(chat_content) - for content in chat_content: + for index, content in enumerate(contents): if isinstance(content, conversation.ToolResultContent): external_tool = True if content.tool_name == "web_search": @@ -322,14 +323,26 @@ def _convert_content( # noqa: C901 else: messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] elif isinstance(content, conversation.UserContent): + has_text = bool(content.content.strip()) + # Attachments are only appended to the last message afterwards, so + # an empty message is only useful for attachments if it is last + has_attachments = bool(content.attachments) and index == len(contents) - 1 + if not has_text and not has_attachments: + # The API rejects whitespace-only text blocks and empty + # messages, so drop content that carries neither text nor + # usable attachments + continue # Combine consequent user messages if not messages or messages[-1]["role"] != "user": messages.append( MessageParam( role="user", - content=content.content, + content=content.content if has_text else [], ) ) + elif not has_text: + # Attachments are appended to the last user message later + continue elif isinstance(messages[-1]["content"], str): messages[-1]["content"] = [ TextBlockParam(type="text", text=messages[-1]["content"]), @@ -375,7 +388,7 @@ def _convert_content( # noqa: C901 ): container_id = content.native.container.id - if content.content: + if content.content and content.content.strip(): current_index = 0 for detail in ( content.native.citation_details @@ -455,7 +468,11 @@ def _convert_content( # noqa: C901 ] ) - if ( + if not messages[-1]["content"]: + # Drop assistant messages that ended up without any content + # (e.g. whitespace-only text): the API rejects empty messages + messages.pop() + elif ( isinstance(messages[-1]["content"], list) and len(messages[-1]["content"]) == 1 and messages[-1]["content"][0]["type"] == "text" diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py index bfe4e2b4274e..59aa1f0e4987 100644 --- a/tests/components/anthropic/test_ai_task.py +++ b/tests/components/anthropic/test_ai_task.py @@ -579,3 +579,51 @@ async def test_generate_data_invalid_attachments( {"media_content_id": "media-source://media/doorbell_snapshot.txt"}, ], ) + + +async def test_generate_data_with_attachments_whitespace_instructions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test whitespace-only instructions with attachments produce no text block. + + The API rejects whitespace-only text blocks, so the user message should + contain only the attachment. + """ + entity_id = "ai_task.claude_ai_task" + + mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])] + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpg", + path=Path("doorbell_snapshot.jpg"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions=" ", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + assert result.data == "Hi there!" + + input_messages = mock_create_stream.call_args[1]["messages"] + user_message = input_messages[-2] + assert user_message["role"] == "user" + assert isinstance(user_message["content"], list) + assert [block["type"] for block in user_message["content"]] == ["image"] diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index bf123f6771b1..679b857cad84 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1,6 +1,7 @@ """Tests for the Anthropic integration.""" import datetime +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -56,7 +57,11 @@ from homeassistant.components.anthropic.const import ( CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, ) -from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails +from homeassistant.components.anthropic.entity import ( + CitationDetails, + ContentDetails, + _convert_content, +) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.const import CONF_LLM_HASS_API @@ -2392,3 +2397,103 @@ async def test_history_conversion( ) assert mock_create_stream.mock_calls[0][2]["messages"] == snapshot + + +async def test_history_conversion_skips_whitespace_content( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test that whitespace-only chat log content is not sent to the API. + + The API rejects text content blocks that contain only whitespace, and a + single such entry in a reused chat session would fail every following turn. + """ + conversation_id = "conversation_id" + mock_create_stream.return_value = [create_content_block(0, ["Yes, I am sure!"])] + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + chat_log.content = [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", content="\n" + ), + conversation.chat_log.UserContent(" "), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", content="Round." + ), + ] + + await conversation.async_converse( + hass, + "Are you sure?", + conversation_id, + Context(), + agent_id="conversation.claude_conversation", + ) + + assert mock_create_stream.mock_calls[0][2]["messages"] == [ + {"role": "user", "content": "What shape is a donut?"}, + {"role": "assistant", "content": "Round."}, + {"role": "user", "content": "Are you sure?"}, + {"role": "assistant", "content": "Yes, I am sure!"}, + ] + + +def test_convert_content_whitespace_with_attachments() -> None: + """Test conversion of whitespace-only user content carrying attachments. + + Attachments are only appended to the last message afterwards, so an empty + user message is only created when the content is the last entry; earlier + whitespace-only entries are dropped even if they carry attachments. + """ + attachment = conversation.chat_log.Attachment( + media_content_id="media-source://media/doorbell_snapshot.jpg", + mime_type="image/jpg", + path=Path("doorbell_snapshot.jpg"), + ) + + # Not the last entry: dropped, surrounding user messages are combined + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent("Take a look"), + conversation.chat_log.UserContent(" ", attachments=[attachment]), + conversation.chat_log.UserContent("What do you see?"), + ] + ) + assert messages == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Take a look"}, + {"type": "text", "text": "What do you see?"}, + ], + }, + ] + + # Last entry preceded by a user message: no text block is added, the + # attachments are appended to the preceding message afterwards + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent("Take a look"), + conversation.chat_log.UserContent(" ", attachments=[attachment]), + ] + ) + assert messages == [ + {"role": "user", "content": "Take a look"}, + ] + + # Last entry with no preceding user message: an empty message is created + # for the attachments to be appended to afterwards + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent(" ", attachments=[attachment]), + ] + ) + assert messages == [ + {"role": "user", "content": []}, + ] From 8e3288ee024038aafde29c2b233fcf4a5824229f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 11:52:50 +0200 Subject: [PATCH 338/707] Move Profiler services to async_setup (#175478) --- homeassistant/components/profiler/__init__.py | 622 +----------------- homeassistant/components/profiler/services.py | 591 +++++++++++++++++ tests/components/profiler/test_init.py | 4 +- 3 files changed, 604 insertions(+), 613 deletions(-) create mode 100644 homeassistant/components/profiler/services.py diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 39463745a0f3..1f61fb0a95a5 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,631 +1,31 @@ """The profiler integration.""" -import asyncio -from collections.abc import Generator -import contextlib -from contextlib import suppress -from datetime import timedelta -from functools import _lru_cache_wrapper -import logging -import reprlib -import sys -import threading -import time -import traceback -from typing import Any, cast - -from lru import LRU -import voluptuous as vol - -from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import LOG_INTERVAL_SUB, async_setup_services -SERVICE_START = "start" -SERVICE_MEMORY = "memory" -SERVICE_START_LOG_OBJECTS = "start_log_objects" -SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" -SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" -SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" -SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" -SERVICE_DUMP_SOCKETS = "dump_sockets" -SERVICE_LRU_STATS = "lru_stats" -SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" -SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" -SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug" -SERVICE_LOG_CURRENT_TASKS = "log_current_tasks" - -_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ -_SQLALCHEMY_LRU_OBJECT = "LRUCache" - -_KNOWN_LRU_CLASSES = ( - "EventDataManager", - "EventTypeManager", - "StatesMetaManager", - "StateAttributesManager", - "StatisticsMetaManager", -) - -SERVICES = ( - SERVICE_START, - SERVICE_MEMORY, - SERVICE_START_LOG_OBJECTS, - SERVICE_STOP_LOG_OBJECTS, - SERVICE_DUMP_LOG_OBJECTS, - SERVICE_LRU_STATS, - SERVICE_LOG_THREAD_FRAMES, - SERVICE_LOG_EVENT_LOOP_SCHEDULED, - SERVICE_SET_ASYNCIO_DEBUG, - SERVICE_LOG_CURRENT_TASKS, -) - -DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) - -DEFAULT_MAX_OBJECTS = 5 - -CONF_SECONDS = "seconds" -CONF_MAX_OBJECTS = "max_objects" - -LOG_INTERVAL_SUB = "log_interval_subscription" +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -_LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Profiler.""" + async_setup_services(hass) + return True -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Profiler from a config entry.""" - lock = asyncio.Lock() - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - domain_data = hass.data[DOMAIN] = {} - - async def _async_run_profile(call: ServiceCall) -> None: - async with lock: - await _async_generate_profile(hass, call) - - async def _async_run_memory_profile(call: ServiceCall) -> None: - async with lock: - await _async_generate_memory_profile(hass, call) - - async def _async_start_log_objects(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB in domain_data: - raise HomeAssistantError("Object logging already started") - - persistent_notification.async_create( - hass, - ( - "Object growth logging has started. See [the logs](/config/logs) to" - " track the growth of new objects." - ), - title="Object growth logging started", - notification_id="profile_object_logging", - ) - await hass.async_add_executor_job(_log_objects) - domain_data[LOG_INTERVAL_SUB] = async_track_time_interval( - hass, _log_objects, call.data[CONF_SCAN_INTERVAL] - ) - - async def _async_stop_log_objects(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB not in domain_data: - raise HomeAssistantError("Object logging not running") - - persistent_notification.async_dismiss(hass, "profile_object_logging") - domain_data.pop(LOG_INTERVAL_SUB)() - - async def _async_start_object_sources(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB in domain_data: - raise HomeAssistantError("Object logging already started") - - persistent_notification.async_create( - hass, - ( - "Object source logging has started. See [the logs](/config/logs) to" - " track the growth of new objects." - ), - title="Object source logging started", - notification_id="profile_object_source_logging", - ) - - last_ids: set[int] = set() - last_stats: dict[str, int] = {} - - async def _log_object_sources_with_max(*_: Any) -> None: - await hass.async_add_executor_job( - _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats - ) - - await _log_object_sources_with_max() - cancel_track = async_track_time_interval( - hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] - ) - - @callback - def _cancel(): - cancel_track() - last_ids.clear() - last_stats.clear() - - domain_data[LOG_INTERVAL_SUB] = _cancel - - @callback - def _async_stop_object_sources(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB not in domain_data: - raise HomeAssistantError("Object logging not running") - - persistent_notification.async_dismiss(hass, "profile_object_source_logging") - domain_data.pop(LOG_INTERVAL_SUB)() - - def _dump_log_objects(call: ServiceCall) -> None: - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - obj_type = call.data[CONF_TYPE] - - for obj in objgraph.by_type(obj_type): - _LOGGER.critical( - "%s object in memory: %s", - obj_type, - _safe_repr(obj), - ) - - persistent_notification.create( - hass, - ( - f"Objects with type {obj_type} have been dumped to the log. See [the" - " logs](/config/logs) to review the repr of the objects." - ), - title="Object dump completed", - notification_id="profile_object_dump", - ) - - def _lru_stats(call: ServiceCall) -> None: - """Log the stats of all lru caches.""" - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): - lru = cast(_lru_cache_wrapper, lru) - _LOGGER.critical( - "Cache stats for lru_cache %s at %s: %s", - lru.__wrapped__, - _get_function_absfile(lru.__wrapped__) or "unknown", - lru.cache_info(), - ) - - for _class in _KNOWN_LRU_CLASSES: - for class_with_lru_attr in objgraph.by_type(_class): - for maybe_lru in class_with_lru_attr.__dict__.values(): - if isinstance(maybe_lru, LRU): - _LOGGER.critical( - "Cache stats for LRU %s at %s: %s", - type(class_with_lru_attr), - _get_function_absfile(class_with_lru_attr) or "unknown", - maybe_lru.get_stats(), - ) - - for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): - if (data := getattr(lru, "_data", None)) and isinstance(data, dict): - for key, value in dict(data).items(): - _LOGGER.critical( - "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value - ) - - persistent_notification.create( - hass, - ( - "LRU cache states have been dumped to the log. See [the" - " logs](/config/logs) to review the stats." - ), - title="LRU stats completed", - notification_id="profile_lru_stats", - ) - - def _dump_sockets(call: ServiceCall) -> None: - """Dump list of all currently existing sockets to the log.""" - import objgraph # noqa: PLC0415 - - _LOGGER.critical( - "Sockets used by Home Assistant:\n%s", - "\n".join(repr(sock) for sock in objgraph.by_type("socket")), - ) - - async def _async_dump_thread_frames(call: ServiceCall) -> None: - """Log all thread frames.""" - frames = sys._current_frames() # noqa: SLF001 - main_thread = threading.main_thread() - for thread in threading.enumerate(): - if thread == main_thread: - continue - ident = cast(int, thread.ident) - _LOGGER.critical( - "Thread [%s]: %s", - thread.name, - "".join(traceback.format_stack(frames.get(ident))).strip(), - ) - - async def _async_dump_current_tasks(call: ServiceCall) -> None: - """Log all current tasks in the event loop.""" - with _increase_repr_limit(): - for task in asyncio.all_tasks(): - if not task.cancelled(): - _LOGGER.critical("Task: %s", _safe_repr(task)) - - async def _async_dump_scheduled(call: ServiceCall) -> None: - """Log all scheduled in the event loop.""" - with _increase_repr_limit(): - handle: asyncio.Handle - for handle in getattr(hass.loop, "_scheduled"): # noqa: B009 - if not handle.cancelled(): - _LOGGER.critical("Scheduled: %s", handle) - - async def _async_asyncio_debug(call: ServiceCall) -> None: - """Enable or disable asyncio debug.""" - enabled = call.data[CONF_ENABLED] - # Always log this at critical level so we know when - # it's been changed when reviewing logs - _LOGGER.critical("Setting asyncio debug to %s", enabled) - # Make sure the logger is set to at least INFO or - # we won't see the messages - base_logger = logging.getLogger() - if enabled and base_logger.getEffectiveLevel() > logging.INFO: - base_logger.setLevel(logging.INFO) - hass.loop.set_debug(enabled) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START, - _async_run_profile, - schema=vol.Schema( - {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_MEMORY, - _async_run_memory_profile, - schema=vol.Schema( - {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START_LOG_OBJECTS, - _async_start_log_objects, - schema=vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period - } - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_STOP_LOG_OBJECTS, - _async_stop_log_objects, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START_LOG_OBJECT_SOURCES, - _async_start_object_sources, - schema=vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( - min=1, max=1024 - ), - } - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_STOP_LOG_OBJECT_SOURCES, - _async_stop_object_sources, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DUMP_LOG_OBJECTS, - _dump_log_objects, - schema=vol.Schema({vol.Required(CONF_TYPE): str}), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DUMP_SOCKETS, - _dump_sockets, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LRU_STATS, - _lru_stats, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_THREAD_FRAMES, - _async_dump_thread_frames, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_EVENT_LOOP_SCHEDULED, - _async_dump_scheduled, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ASYNCIO_DEBUG, - _async_asyncio_debug, - schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_CURRENT_TASKS, - _async_dump_current_tasks, - ) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - for service in SERVICES: - hass.services.async_remove(domain=DOMAIN, service=service) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data if LOG_INTERVAL_SUB in hass.data[DOMAIN]: hass.data[DOMAIN][LOG_INTERVAL_SUB]() - hass.data.pop(DOMAIN) return True - - -async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import cProfile # noqa: PLC0415 - - start_time = int(time.time() * 1000000) - persistent_notification.async_create( - hass, - ( - "The profile has started. This notification will be updated when it is" - " complete." - ), - title="Profile Started", - notification_id=f"profiler_{start_time}", - ) - profiler = cProfile.Profile() - profiler.enable() - await asyncio.sleep(float(call.data[CONF_SECONDS])) - profiler.disable() - - cprofile_path = hass.config.path(f"profile.{start_time}.cprof") - callgrind_path = hass.config.path(f"callgrind.out.{start_time}") - await hass.async_add_executor_job( - _write_profile, profiler, cprofile_path, callgrind_path - ) - persistent_notification.async_create( - hass, - ( - f"Wrote cProfile data to {cprofile_path} and callgrind data to" - f" {callgrind_path}" - ), - title="Profile Complete", - notification_id=f"profiler_{start_time}", - ) - - -async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - from guppy import hpy # noqa: PLC0415 - - start_time = int(time.time() * 1000000) - persistent_notification.async_create( - hass, - ( - "The memory profile has started. This notification will be updated when it" - " is complete." - ), - title="Profile Started", - notification_id=f"memory_profiler_{start_time}", - ) - heap_profiler = hpy() - heap_profiler.setref() - await asyncio.sleep(float(call.data[CONF_SECONDS])) - heap = heap_profiler.heap() - - heap_path = hass.config.path(f"heap_profile.{start_time}.hpy") - await hass.async_add_executor_job(_write_memory_profile, heap, heap_path) - persistent_notification.async_create( - hass, - f"Wrote heapy memory profile to {heap_path}", - title="Profile Complete", - notification_id=f"memory_profiler_{start_time}", - ) - - -def _write_profile(profiler, cprofile_path, callgrind_path): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - from pyprof2calltree import convert # noqa: PLC0415 - - profiler.create_stats() - profiler.dump_stats(cprofile_path) - convert(profiler.getstats(), callgrind_path) - - -def _write_memory_profile(heap, heap_path): - heap.byrcs.dump(heap_path) - - -def _log_objects(*_): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) - - -def _get_function_absfile(func: Any) -> str | None: - """Get the absolute file path of a function.""" - import inspect # noqa: PLC0415 - - abs_file: str | None = None - with suppress(Exception): - abs_file = inspect.getabsfile(func) - return abs_file - - -def _safe_repr(obj: Any) -> str: - """Get the repr of an object but keep going if there is an exception. - - We wrap repr to ensure if one object cannot be serialized, we can - still get the rest. - """ - try: - return repr(obj) - except Exception: # noqa: BLE001 - return f"Failed to serialize {type(obj)}" - - -def _find_backrefs_not_to_self(_object: Any) -> list[str]: - import objgraph # noqa: PLC0415 - - return [ - _safe_repr(backref) - for backref in objgraph.find_backref_chain( - _object, lambda obj: obj is not _object - ) - ] - - -def _log_object_sources( - max_objects: int, last_ids: set[int], last_stats: dict[str, int] -) -> None: - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import gc # noqa: PLC0415 - - gc.collect() - - objects = gc.get_objects() - new_objects: list[object] = [] - new_objects_overflow: dict[str, int] = {} - current_ids = set() - new_stats: dict[str, int] = {} - had_new_object_growth = False - try: - for _object in objects: - object_type = type(_object).__name__ - new_stats[object_type] = new_stats.get(object_type, 0) + 1 - - for _object in objects: - id_ = id(_object) - current_ids.add(id_) - if id_ in last_ids: - continue - object_type = type(_object).__name__ - if last_stats.get(object_type, 0) < new_stats[object_type]: - if len(new_objects) < max_objects: - new_objects.append(_object) - else: - new_objects_overflow.setdefault(object_type, 0) - new_objects_overflow[object_type] += 1 - - for _object in new_objects: - had_new_object_growth = True - object_type = type(_object).__name__ - _LOGGER.critical( - "New object %s (%s/%s) at %s: %s", - object_type, - last_stats.get(object_type, 0), - new_stats[object_type], - _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), - _safe_repr(_object), - ) - - for object_type, count in last_stats.items(): - new_stats[object_type] = max(new_stats.get(object_type, 0), count) - finally: - # Break reference cycles - del objects - del new_objects - last_ids.clear() - last_ids.update(current_ids) - last_stats.clear() - last_stats.update(new_stats) - del new_stats - del current_ids - - if new_objects_overflow: - _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) - elif not had_new_object_growth: - _LOGGER.critical("No new object growth found") - - -@contextlib.contextmanager -def _increase_repr_limit() -> Generator[None]: - """Increase the repr limit.""" - arepr = reprlib.aRepr - original_maxstring = arepr.maxstring - original_maxother = arepr.maxother - arepr.maxstring = 300 - arepr.maxother = 300 - try: - yield - finally: - arepr.maxstring = original_maxstring - arepr.maxother = original_maxother diff --git a/homeassistant/components/profiler/services.py b/homeassistant/components/profiler/services.py new file mode 100644 index 000000000000..34c217d4f4fd --- /dev/null +++ b/homeassistant/components/profiler/services.py @@ -0,0 +1,591 @@ +"""Support for the profiler services.""" + +import asyncio +from collections.abc import Generator +import contextlib +from contextlib import suppress +from datetime import timedelta +from functools import _lru_cache_wrapper +import logging +import reprlib +import sys +import threading +import time +import traceback +from typing import Any, cast + +from lru import LRU +import voluptuous as vol + +from homeassistant.components import persistent_notification +from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service import async_register_admin_service + +from .const import DOMAIN + +SERVICE_START = "start" +SERVICE_MEMORY = "memory" +SERVICE_START_LOG_OBJECTS = "start_log_objects" +SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" +SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" +SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" +SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" +SERVICE_DUMP_SOCKETS = "dump_sockets" +SERVICE_LRU_STATS = "lru_stats" +SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" +SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" +SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug" +SERVICE_LOG_CURRENT_TASKS = "log_current_tasks" + +_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ +_SQLALCHEMY_LRU_OBJECT = "LRUCache" + +_KNOWN_LRU_CLASSES = ( + "EventDataManager", + "EventTypeManager", + "StatesMetaManager", + "StateAttributesManager", + "StatisticsMetaManager", +) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +DEFAULT_MAX_OBJECTS = 5 + +CONF_SECONDS = "seconds" +CONF_MAX_OBJECTS = "max_objects" + +LOG_INTERVAL_SUB = "log_interval_subscription" + + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 + """Register the profiler services.""" + lock = asyncio.Lock() + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data + domain_data = hass.data[DOMAIN] = {} + + async def _async_run_profile(call: ServiceCall) -> None: + async with lock: + await _async_generate_profile(hass, call) + + async def _async_run_memory_profile(call: ServiceCall) -> None: + async with lock: + await _async_generate_memory_profile(hass, call) + + async def _async_start_log_objects(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") + + persistent_notification.async_create( + hass, + ( + "Object growth logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object growth logging started", + notification_id="profile_object_logging", + ) + await hass.async_add_executor_job(_log_objects) + domain_data[LOG_INTERVAL_SUB] = async_track_time_interval( + hass, _log_objects, call.data[CONF_SCAN_INTERVAL] + ) + + async def _async_stop_log_objects(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_logging") + domain_data.pop(LOG_INTERVAL_SUB)() + + async def _async_start_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") + + persistent_notification.async_create( + hass, + ( + "Object source logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object source logging started", + notification_id="profile_object_source_logging", + ) + + last_ids: set[int] = set() + last_stats: dict[str, int] = {} + + async def _log_object_sources_with_max(*_: Any) -> None: + await hass.async_add_executor_job( + _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats + ) + + await _log_object_sources_with_max() + cancel_track = async_track_time_interval( + hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] + ) + + @callback + def _cancel(): + cancel_track() + last_ids.clear() + last_stats.clear() + + domain_data[LOG_INTERVAL_SUB] = _cancel + + @callback + def _async_stop_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_source_logging") + domain_data.pop(LOG_INTERVAL_SUB)() + + def _dump_log_objects(call: ServiceCall) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + obj_type = call.data[CONF_TYPE] + + for obj in objgraph.by_type(obj_type): + _LOGGER.critical( + "%s object in memory: %s", + obj_type, + _safe_repr(obj), + ) + + persistent_notification.create( + hass, + ( + f"Objects with type {obj_type} have been dumped to the log. See [the" + " logs](/config/logs) to review the repr of the objects." + ), + title="Object dump completed", + notification_id="profile_object_dump", + ) + + def _lru_stats(call: ServiceCall) -> None: + """Log the stats of all lru caches.""" + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): + lru = cast(_lru_cache_wrapper, lru) + _LOGGER.critical( + "Cache stats for lru_cache %s at %s: %s", + lru.__wrapped__, + _get_function_absfile(lru.__wrapped__) or "unknown", + lru.cache_info(), + ) + + for _class in _KNOWN_LRU_CLASSES: + for class_with_lru_attr in objgraph.by_type(_class): + for maybe_lru in class_with_lru_attr.__dict__.values(): + if isinstance(maybe_lru, LRU): + _LOGGER.critical( + "Cache stats for LRU %s at %s: %s", + type(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr) or "unknown", + maybe_lru.get_stats(), + ) + + for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): + if (data := getattr(lru, "_data", None)) and isinstance(data, dict): + for key, value in dict(data).items(): + _LOGGER.critical( + "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value + ) + + persistent_notification.create( + hass, + ( + "LRU cache states have been dumped to the log. See [the" + " logs](/config/logs) to review the stats." + ), + title="LRU stats completed", + notification_id="profile_lru_stats", + ) + + def _dump_sockets(call: ServiceCall) -> None: + """Dump list of all currently existing sockets to the log.""" + import objgraph # noqa: PLC0415 + + _LOGGER.critical( + "Sockets used by Home Assistant:\n%s", + "\n".join(repr(sock) for sock in objgraph.by_type("socket")), + ) + + async def _async_dump_thread_frames(call: ServiceCall) -> None: + """Log all thread frames.""" + frames = sys._current_frames() # noqa: SLF001 + main_thread = threading.main_thread() + for thread in threading.enumerate(): + if thread == main_thread: + continue + ident = cast(int, thread.ident) + _LOGGER.critical( + "Thread [%s]: %s", + thread.name, + "".join(traceback.format_stack(frames.get(ident))).strip(), + ) + + async def _async_dump_current_tasks(call: ServiceCall) -> None: + """Log all current tasks in the event loop.""" + with _increase_repr_limit(): + for task in asyncio.all_tasks(): + if not task.cancelled(): + _LOGGER.critical("Task: %s", _safe_repr(task)) + + async def _async_dump_scheduled(call: ServiceCall) -> None: + """Log all scheduled in the event loop.""" + with _increase_repr_limit(): + handle: asyncio.Handle + for handle in getattr(hass.loop, "_scheduled"): # noqa: B009 + if not handle.cancelled(): + _LOGGER.critical("Scheduled: %s", handle) + + async def _async_asyncio_debug(call: ServiceCall) -> None: + """Enable or disable asyncio debug.""" + enabled = call.data[CONF_ENABLED] + # Always log this at critical level so we know when + # it's been changed when reviewing logs + _LOGGER.critical("Setting asyncio debug to %s", enabled) + # Make sure the logger is set to at least INFO or + # we won't see the messages + base_logger = logging.getLogger() + if enabled and base_logger.getEffectiveLevel() > logging.INFO: + base_logger.setLevel(logging.INFO) + hass.loop.set_debug(enabled) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START, + _async_run_profile, + schema=vol.Schema( + {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_MEMORY, + _async_run_memory_profile, + schema=vol.Schema( + {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECTS, + _async_start_log_objects, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECTS, + _async_stop_log_objects, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + _async_start_object_sources, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( + min=1, max=1024 + ), + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECT_SOURCES, + _async_stop_object_sources, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DUMP_LOG_OBJECTS, + _dump_log_objects, + schema=vol.Schema({vol.Required(CONF_TYPE): str}), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DUMP_SOCKETS, + _dump_sockets, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LRU_STATS, + _lru_stats, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_THREAD_FRAMES, + _async_dump_thread_frames, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_EVENT_LOOP_SCHEDULED, + _async_dump_scheduled, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ASYNCIO_DEBUG, + _async_asyncio_debug, + schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_CURRENT_TASKS, + _async_dump_current_tasks, + ) + + +async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import cProfile # noqa: PLC0415 + + start_time = int(time.time() * 1000000) + persistent_notification.async_create( + hass, + ( + "The profile has started. This notification will be updated when it is" + " complete." + ), + title="Profile Started", + notification_id=f"profiler_{start_time}", + ) + profiler = cProfile.Profile() + profiler.enable() + await asyncio.sleep(float(call.data[CONF_SECONDS])) + profiler.disable() + + cprofile_path = hass.config.path(f"profile.{start_time}.cprof") + callgrind_path = hass.config.path(f"callgrind.out.{start_time}") + await hass.async_add_executor_job( + _write_profile, profiler, cprofile_path, callgrind_path + ) + persistent_notification.async_create( + hass, + ( + f"Wrote cProfile data to {cprofile_path} and callgrind data to" + f" {callgrind_path}" + ), + title="Profile Complete", + notification_id=f"profiler_{start_time}", + ) + + +async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + from guppy import hpy # noqa: PLC0415 + + start_time = int(time.time() * 1000000) + persistent_notification.async_create( + hass, + ( + "The memory profile has started. This notification will be updated when it" + " is complete." + ), + title="Profile Started", + notification_id=f"memory_profiler_{start_time}", + ) + heap_profiler = hpy() + heap_profiler.setref() + await asyncio.sleep(float(call.data[CONF_SECONDS])) + heap = heap_profiler.heap() + + heap_path = hass.config.path(f"heap_profile.{start_time}.hpy") + await hass.async_add_executor_job(_write_memory_profile, heap, heap_path) + persistent_notification.async_create( + hass, + f"Wrote heapy memory profile to {heap_path}", + title="Profile Complete", + notification_id=f"memory_profiler_{start_time}", + ) + + +def _write_profile(profiler, cprofile_path, callgrind_path): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + from pyprof2calltree import convert # noqa: PLC0415 + + profiler.create_stats() + profiler.dump_stats(cprofile_path) + convert(profiler.getstats(), callgrind_path) + + +def _write_memory_profile(heap, heap_path): + heap.byrcs.dump(heap_path) + + +def _log_objects(*_): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) + + +def _get_function_absfile(func: Any) -> str | None: + """Get the absolute file path of a function.""" + import inspect # noqa: PLC0415 + + abs_file: str | None = None + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + +def _safe_repr(obj: Any) -> str: + """Get the repr of an object but keep going if there is an exception. + + We wrap repr to ensure if one object cannot be serialized, we can + still get the rest. + """ + try: + return repr(obj) + except Exception: # noqa: BLE001 + return f"Failed to serialize {type(obj)}" + + +def _find_backrefs_not_to_self(_object: Any) -> list[str]: + import objgraph # noqa: PLC0415 + + return [ + _safe_repr(backref) + for backref in objgraph.find_backref_chain( + _object, lambda obj: obj is not _object + ) + ] + + +def _log_object_sources( + max_objects: int, last_ids: set[int], last_stats: dict[str, int] +) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import gc # noqa: PLC0415 + + gc.collect() + + objects = gc.get_objects() + new_objects: list[object] = [] + new_objects_overflow: dict[str, int] = {} + current_ids = set() + new_stats: dict[str, int] = {} + had_new_object_growth = False + try: + for _object in objects: + object_type = type(_object).__name__ + new_stats[object_type] = new_stats.get(object_type, 0) + 1 + + for _object in objects: + id_ = id(_object) + current_ids.add(id_) + if id_ in last_ids: + continue + object_type = type(_object).__name__ + if last_stats.get(object_type, 0) < new_stats[object_type]: + if len(new_objects) < max_objects: + new_objects.append(_object) + else: + new_objects_overflow.setdefault(object_type, 0) + new_objects_overflow[object_type] += 1 + + for _object in new_objects: + had_new_object_growth = True + object_type = type(_object).__name__ + _LOGGER.critical( + "New object %s (%s/%s) at %s: %s", + object_type, + last_stats.get(object_type, 0), + new_stats[object_type], + _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), + _safe_repr(_object), + ) + + for object_type, count in last_stats.items(): + new_stats[object_type] = max(new_stats.get(object_type, 0), count) + finally: + # Break reference cycles + del objects + del new_objects + last_ids.clear() + last_ids.update(current_ids) + last_stats.clear() + last_stats.update(new_stats) + del new_stats + del current_ids + + if new_objects_overflow: + _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) + elif not had_new_object_growth: + _LOGGER.critical("No new object growth found") + + +@contextlib.contextmanager +def _increase_repr_limit() -> Generator[None]: + """Increase the repr limit.""" + arepr = reprlib.aRepr + original_maxstring = arepr.maxstring + original_maxother = arepr.maxother + arepr.maxstring = 300 + arepr.maxother = 300 + try: + yield + finally: + arepr.maxstring = original_maxstring + arepr.maxother = original_maxother diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index f2d0befe630e..f450dd447425 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -13,7 +13,8 @@ from lru import LRU import objgraph import pytest -from homeassistant.components.profiler import ( +from homeassistant.components.profiler.const import DOMAIN +from homeassistant.components.profiler.services import ( _LRU_CACHE_WRAPPER_OBJECT, _SQLALCHEMY_LRU_OBJECT, CONF_SECONDS, @@ -31,7 +32,6 @@ from homeassistant.components.profiler import ( SERVICE_STOP_LOG_OBJECT_SOURCES, SERVICE_STOP_LOG_OBJECTS, ) -from homeassistant.components.profiler.const import DOMAIN from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError From a09569d72fe91fd7c3fe7732190aa10bdf6acfd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:21:17 +0200 Subject: [PATCH 339/707] Bump docker/setup-buildx-action from 4.1.0 to 4.2.0 (#176064) Signed-off-by: dependabot[bot] --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 6ee9ad951d96..3631191edf8d 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -392,7 +392,7 @@ jobs: type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v3.7.1 - name: Copy architecture images to DockerHub if: matrix.registry == 'docker.io/homeassistant' From f84af82237112fc56e6886b6ad3d6fc5cf4e16fa Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 12:21:48 +0200 Subject: [PATCH 340/707] Portainer refactor setup with lightweigth API call (#173036) --- homeassistant/components/portainer/coordinator.py | 2 +- tests/components/portainer/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index ba5357fb58a7..a876fa2a1135 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -136,7 +136,7 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): async def _async_setup(self) -> None: """Set up the Portainer Data Update Coordinator.""" try: - await self.portainer.get_endpoints() + await self.portainer.portainer_system_status() except PortainerAuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index aa435a522e2f..dab13e650cf2 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -53,7 +53,7 @@ async def test_setup_exceptions( expected_state: ConfigEntryState, ) -> None: """Test the _async_setup.""" - mock_portainer_client.get_endpoints.side_effect = exception + mock_portainer_client.portainer_system_status.side_effect = exception await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state From 6ecf0f29570856214526ceed7450e93f3e54f8a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:27:02 +0200 Subject: [PATCH 341/707] Bump dorny/paths-filter from 4.0.1 to 4.0.2 (#176061) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 32909f1dbb31..e026da1a34b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -116,7 +116,7 @@ jobs: # of a new uv cache entry after a version bump. echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: core with: filters: .core_files.yaml @@ -131,7 +131,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: integrations with: filters: .integration_paths.yaml From ee9933edd9d7bc189bb82a2a1c798affa5d92394 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 12:28:46 +0200 Subject: [PATCH 342/707] Move Shopping List services to async_setup (#175477) --- .../components/shopping_list/__init__.py | 137 +----------------- .../components/shopping_list/common.py | 10 +- .../components/shopping_list/services.py | 132 +++++++++++++++++ 3 files changed, 141 insertions(+), 138 deletions(-) create mode 100644 homeassistant/components/shopping_list/services.py diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index c1b7eaf54fde..a2527f5d8307 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -10,14 +10,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, - callback, -) -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.const import Platform +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from .common import ( @@ -26,19 +21,8 @@ from .common import ( ShoppingListConfigEntry, _get_shopping_data, ) -from .const import ( - ATTR_REVERSE, - DEFAULT_REVERSE, - DOMAIN, - SERVICE_ADD_ITEM, - SERVICE_CLEAR_COMPLETED_ITEMS, - SERVICE_COMPLETE_ALL, - SERVICE_COMPLETE_ITEM, - SERVICE_INCOMPLETE_ALL, - SERVICE_INCOMPLETE_ITEM, - SERVICE_REMOVE_ITEM, - SERVICE_SORT, -) +from .const import DOMAIN +from .services import async_register_services PLATFORMS = [Platform.TODO] @@ -46,15 +30,10 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) -SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) -SERVICE_LIST_SCHEMA = vol.Schema({}) -SERVICE_SORT_SCHEMA = vol.Schema( - {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the shopping list.""" + async_register_services(hass) if DOMAIN not in config: return True @@ -88,113 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ShoppingListConfigEntry ) -> bool: """Set up shopping list from config flow.""" - - async def add_item_service(call: ServiceCall) -> None: - """Add an item with `name`.""" - await config_entry.runtime_data.async_add(call.data[ATTR_NAME]) - - async def remove_item_service(call: ServiceCall) -> None: - """Remove the first item with matching `name`.""" - data = config_entry.runtime_data - name = call.data[ATTR_NAME] - - try: - item = [item for item in data.items if item["name"] == name][0] - # pylint: disable-next=home-assistant-action-swallowed-exception - except IndexError: - _LOGGER.error("Removing of item failed: %s cannot be found", name) - else: - await data.async_remove(str(item["id"])) - - async def complete_item_service(call: ServiceCall) -> None: - """Mark the first item with matching `name` as completed.""" - name = call.data[ATTR_NAME] - try: - await config_entry.runtime_data.async_complete(name) - # pylint: disable-next=home-assistant-action-swallowed-exception - except NoMatchingShoppingListItem: - _LOGGER.error("Completing of item failed: %s cannot be found", name) - - async def incomplete_item_service(call: ServiceCall) -> None: - """Mark the first item with matching `name` as incomplete.""" - data = config_entry.runtime_data - name = call.data[ATTR_NAME] - - try: - item = [item for item in data.items if item["name"] == name][0] - # pylint: disable-next=home-assistant-action-swallowed-exception - except IndexError: - _LOGGER.error("Restoring of item failed: %s cannot be found", name) - else: - await data.async_update(str(item["id"]), {"name": name, "complete": False}) - - async def complete_all_service(call: ServiceCall) -> None: - """Mark all items in the list as complete.""" - await data.async_update_list({"complete": True}) - - async def incomplete_all_service(call: ServiceCall) -> None: - """Mark all items in the list as incomplete.""" - await data.async_update_list({"complete": False}) - - async def clear_completed_items_service(call: ServiceCall) -> None: - """Clear all completed items from the list.""" - await data.async_clear_completed() - - async def sort_list_service(call: ServiceCall) -> None: - """Sort all items by name.""" - await data.async_sort(call.data[ATTR_REVERSE]) - data = config_entry.runtime_data = ShoppingData(hass) await data.async_load() - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_INCOMPLETE_ITEM, - incomplete_item_service, - schema=SERVICE_ITEM_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_COMPLETE_ALL, - complete_all_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_INCOMPLETE_ALL, - incomplete_all_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_CLEAR_COMPLETED_ITEMS, - clear_completed_items_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SORT, - sort_list_service, - schema=SERVICE_SORT_SCHEMA, - ) - hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) hass.http.register_view(UpdateShoppingListItemView) diff --git a/homeassistant/components/shopping_list/common.py b/homeassistant/components/shopping_list/common.py index 4a8523c39e32..4305ba84bded 100644 --- a/homeassistant/components/shopping_list/common.py +++ b/homeassistant/components/shopping_list/common.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import service from homeassistant.helpers.json import save_json from homeassistant.util.json import JsonValueType, load_json_array @@ -271,9 +271,5 @@ class ShoppingData: def _get_shopping_data(hass: HomeAssistant) -> ShoppingData: - entries: list[ShoppingListConfigEntry] = hass.config_entries.async_loaded_entries( - DOMAIN - ) - if not entries: - raise HomeAssistantError("No shopping list config entry found") - return entries[0].runtime_data + entry: ShoppingListConfigEntry = service.async_get_config_entry(hass, DOMAIN, None) + return entry.runtime_data diff --git a/homeassistant/components/shopping_list/services.py b/homeassistant/components/shopping_list/services.py new file mode 100644 index 000000000000..693c0d15b0be --- /dev/null +++ b/homeassistant/components/shopping_list/services.py @@ -0,0 +1,132 @@ +"""Support for shopping list services.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .common import NoMatchingShoppingListItem, _get_shopping_data +from .const import ( + ATTR_REVERSE, + DEFAULT_REVERSE, + DOMAIN, + SERVICE_ADD_ITEM, + SERVICE_CLEAR_COMPLETED_ITEMS, + SERVICE_COMPLETE_ALL, + SERVICE_COMPLETE_ITEM, + SERVICE_INCOMPLETE_ALL, + SERVICE_INCOMPLETE_ITEM, + SERVICE_REMOVE_ITEM, + SERVICE_SORT, +) + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) +SERVICE_LIST_SCHEMA = vol.Schema({}) +SERVICE_SORT_SCHEMA = vol.Schema( + {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} +) + + +@callback +def async_register_services(hass: HomeAssistant) -> None: + """Register shopping list services.""" + + async def add_item_service(call: ServiceCall) -> None: + """Add an item with `name`.""" + await _get_shopping_data(hass).async_add(call.data[ATTR_NAME]) + + async def remove_item_service(call: ServiceCall) -> None: + """Remove the first item with matching `name`.""" + data = _get_shopping_data(hass) + name = call.data[ATTR_NAME] + + try: + item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception + except IndexError: + _LOGGER.error("Removing of item failed: %s cannot be found", name) + else: + await data.async_remove(str(item["id"])) + + async def complete_item_service(call: ServiceCall) -> None: + """Mark the first item with matching `name` as completed.""" + name = call.data[ATTR_NAME] + try: + await _get_shopping_data(hass).async_complete(name) + # pylint: disable-next=home-assistant-action-swallowed-exception + except NoMatchingShoppingListItem: + _LOGGER.error("Completing of item failed: %s cannot be found", name) + + async def incomplete_item_service(call: ServiceCall) -> None: + """Mark the first item with matching `name` as incomplete.""" + data = _get_shopping_data(hass) + name = call.data[ATTR_NAME] + + try: + item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception + except IndexError: + _LOGGER.error("Restoring of item failed: %s cannot be found", name) + else: + await data.async_update(str(item["id"]), {"name": name, "complete": False}) + + async def complete_all_service(call: ServiceCall) -> None: + """Mark all items in the list as complete.""" + await _get_shopping_data(hass).async_update_list({"complete": True}) + + async def incomplete_all_service(call: ServiceCall) -> None: + """Mark all items in the list as incomplete.""" + await _get_shopping_data(hass).async_update_list({"complete": False}) + + async def clear_completed_items_service(call: ServiceCall) -> None: + """Clear all completed items from the list.""" + await _get_shopping_data(hass).async_clear_completed() + + async def sort_list_service(call: ServiceCall) -> None: + """Sort all items by name.""" + await _get_shopping_data(hass).async_sort(call.data[ATTR_REVERSE]) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ITEM, + incomplete_item_service, + schema=SERVICE_ITEM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_ALL, + complete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ALL, + incomplete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_COMPLETED_ITEMS, + clear_completed_items_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SORT, + sort_list_service, + schema=SERVICE_SORT_SCHEMA, + ) From bc0412e19c394b5c6d2f5239ee4758238917477e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 12:29:23 +0200 Subject: [PATCH 343/707] Move Rachio services to async_setup (#175479) --- homeassistant/components/rachio/__init__.py | 13 +- homeassistant/components/rachio/device.py | 82 +----------- homeassistant/components/rachio/services.py | 135 ++++++++++++++++++++ homeassistant/components/rachio/switch.py | 59 +-------- 4 files changed, 150 insertions(+), 139 deletions(-) create mode 100644 homeassistant/components/rachio/services.py diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index ab0886096cc7..90de274fb938 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -10,9 +10,12 @@ from homeassistant.components import cloud from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN from .device import RachioConfigEntry, RachioPerson +from .services import async_setup_services from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, async_register_webhook, @@ -23,6 +26,14 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Rachio integration.""" + async_setup_services(hass) + return True + async def async_unload_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 919f323029aa..5bf10c08f23f 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -5,16 +5,13 @@ import logging from typing import Any, override from rachiopy import Rachio -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from .const import ( - DOMAIN, KEY_BASE_STATIONS, KEY_DEVICES, KEY_ENABLED, @@ -30,31 +27,14 @@ from .const import ( KEY_USERNAME, KEY_ZONES, LISTEN_EVENT_TYPES, - MODEL_GENERATION_1, - SERVICE_PAUSE_WATERING, - SERVICE_RESUME_WATERING, - SERVICE_STOP_WATERING, WEBHOOK_CONST_ID, ) from .coordinator import RachioScheduleUpdateCoordinator, RachioUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_DEVICES = "devices" -ATTR_DURATION = "duration" PERMISSION_ERROR = "7" -PAUSE_SERVICE_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_DEVICES): cv.string, - vol.Optional(ATTR_DURATION, default=60): cv.positive_int, - } -) - -RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) - -STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) - type RachioConfigEntry = ConfigEntry[RachioPerson] @@ -72,66 +52,8 @@ class RachioPerson: self._base_stations: list[RachioBaseStation] = [] async def async_setup(self, hass: HomeAssistant) -> None: - """Create rachio devices and services.""" + """Create rachio devices.""" await hass.async_add_executor_job(self._setup, hass) - can_pause = False - for rachio_iro in self._controllers: - # Generation 1 controllers don't support pause or resume - if rachio_iro.model.split("_")[0] != MODEL_GENERATION_1: - can_pause = True - break - - all_controllers = [rachio_iro.name for rachio_iro in self._controllers] - - def pause_water(service: ServiceCall) -> None: - """Service to pause watering on all or specific controllers.""" - duration = service.data[ATTR_DURATION] - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.pause_watering(duration) - - def resume_water(service: ServiceCall) -> None: - """Service to resume watering on all or specific controllers.""" - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.resume_watering() - - def stop_water(service: ServiceCall) -> None: - """Service to stop watering on all or specific controllers.""" - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.stop_watering() - - # If only hose timers on account, none of these services apply - if not all_controllers: - return - - hass.services.async_register( - DOMAIN, - SERVICE_STOP_WATERING, - stop_water, - schema=STOP_SERVICE_SCHEMA, - ) - - if not can_pause: - return - - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_WATERING, - pause_water, - schema=PAUSE_SERVICE_SCHEMA, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_WATERING, - resume_water, - schema=RESUME_SERVICE_SCHEMA, - ) def _setup(self, hass: HomeAssistant) -> None: """Rachio device setup.""" diff --git a/homeassistant/components/rachio/services.py b/homeassistant/components/rachio/services.py new file mode 100644 index 000000000000..1e30e85e4645 --- /dev/null +++ b/homeassistant/components/rachio/services.py @@ -0,0 +1,135 @@ +"""Services for the Rachio integration.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID, Platform +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + service, +) + +from .const import ( + DOMAIN, + KEY_ID, + MODEL_GENERATION_1, + SERVICE_PAUSE_WATERING, + SERVICE_RESUME_WATERING, + SERVICE_START_MULTIPLE_ZONES, + SERVICE_STOP_WATERING, +) +from .device import RachioConfigEntry + +_LOGGER = logging.getLogger(__name__) + +ATTR_DEVICES = "devices" +ATTR_DURATION = "duration" +ATTR_SORT_ORDER = "sortOrder" + +PAUSE_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_DEVICES): cv.string, + vol.Optional(ATTR_DURATION, default=60): cv.positive_int, + } +) + +RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + +START_MULTIPLE_ZONES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_DURATION): cv.ensure_list_csv, + } +) + +STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + + +def _stop_water(call: ServiceCall) -> None: + """Stop watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices: + iro.stop_watering() + + +def _pause_water(call: ServiceCall) -> None: + """Pause watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1: + iro.pause_watering(call.data[ATTR_DURATION]) + + +def _resume_water(call: ServiceCall) -> None: + """Resume watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1: + iro.resume_watering() + + +def _start_multiple(call: ServiceCall) -> None: + """Start multiple zones in sequence.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + entity_reg = er.async_get(call.hass) + duration = iter(call.data[ATTR_DURATION]) + default_time = call.data[ATTR_DURATION][0] + + entity_to_zone_id = { + entity_reg.async_get_entity_id( + Platform.SWITCH, + DOMAIN, + f"{controller.controller_id}-zone-{zone[KEY_ID]}", + ): zone[KEY_ID] + for controller in person.controllers + for zone in controller.list_zones() + } + + zones_list = [ + { + ATTR_ID: entity_to_zone_id[entity_id], + ATTR_DURATION: int(next(duration, default_time)) * 60, + ATTR_SORT_ORDER: count, + } + for count, entity_id in enumerate(call.data[ATTR_ENTITY_ID]) + if entity_id in entity_to_zone_id + ] + + if not zones_list: + raise HomeAssistantError("No matching zones found in given entity_ids") + + person.start_multiple_zones(zones_list) + _LOGGER.debug("Starting zone(s) %s", call.data[ATTR_ENTITY_ID]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Rachio services.""" + + hass.services.async_register( + DOMAIN, SERVICE_STOP_WATERING, _stop_water, schema=STOP_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_PAUSE_WATERING, _pause_water, schema=PAUSE_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_RESUME_WATERING, _resume_water, schema=RESUME_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_MULTIPLE_ZONES, + _start_multiple, + schema=START_MULTIPLE_ZONES_SCHEMA, + ) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 146618fad2be..83025aa03c85 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -9,9 +9,7 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -22,7 +20,6 @@ from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_ti from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DOMAIN, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, @@ -45,7 +42,6 @@ from .const import ( SCHEDULE_TYPE_FIXED, SCHEDULE_TYPE_FLEX, SERVICE_SET_ZONE_MOISTURE, - SERVICE_START_MULTIPLE_ZONES, SERVICE_START_WATERING, SIGNAL_RACHIO_CONTROLLER_UPDATE, SIGNAL_RACHIO_RAIN_DELAY_UPDATE, @@ -80,7 +76,6 @@ ATTR_SCHEDULE_SUMMARY = "Summary" ATTR_SCHEDULE_ENABLED = "Enabled" ATTR_SCHEDULE_DURATION = "Duration" ATTR_SCHEDULE_TYPE = "Type" -ATTR_SORT_ORDER = "sortOrder" ATTR_WATERING_DURATION = "Watering Duration seconds" ATTR_ZONE_NUMBER = "Zone number" ATTR_ZONE_SHADE = "Shade" @@ -88,13 +83,6 @@ ATTR_ZONE_SLOPE = "Slope" ATTR_ZONE_SUMMARY = "Summary" ATTR_ZONE_TYPE = "Type" -START_MULTIPLE_ZONES_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_DURATION): cv.ensure_list_csv, - } -) - async def async_setup_entry( hass: HomeAssistant, @@ -102,47 +90,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" - zone_entities = [] has_flex_sched = False entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) for entity in entities: - if isinstance(entity, RachioZone): - zone_entities.append(entity) if isinstance(entity, RachioSchedule) and entity.type == SCHEDULE_TYPE_FLEX: has_flex_sched = True async_add_entities(entities) - def start_multiple(service: ServiceCall) -> None: - """Service to start multiple zones in sequence.""" - zones_list = [] - person = config_entry.runtime_data - entity_id = service.data[ATTR_ENTITY_ID] - duration = iter(service.data[ATTR_DURATION]) - default_time = service.data[ATTR_DURATION][0] - entity_to_zone_id = { - entity.entity_id: entity.zone_id for entity in zone_entities - } - - for count, data in enumerate(entity_id): - if data in entity_to_zone_id: - # Time can be passed as a list per zone, - # or one time for all zones - time = int(next(duration, default_time)) * 60 - zones_list.append( - { - ATTR_ID: entity_to_zone_id.get(data), - ATTR_DURATION: time, - ATTR_SORT_ORDER: count, - } - ) - - if len(zones_list) != 0: - person.start_multiple_zones(zones_list) - _LOGGER.debug("Starting zone(s) %s", entity_id) - else: - raise HomeAssistantError("No matching zones found in given entity_ids") - platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_START_WATERING, @@ -152,18 +107,6 @@ async def async_setup_entry( "turn_on", ) - # If only hose timers on account, none of these services apply - if not zone_entities: - return - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_START_MULTIPLE_ZONES, - start_multiple, - schema=START_MULTIPLE_ZONES_SCHEMA, - ) - if has_flex_sched: platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( From 4a915cc08f3492dfd9e6ebc094ea594758ec5d66 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Jul 2026 12:29:56 +0200 Subject: [PATCH 344/707] Use last_changed_timestamp directly in history_stats (#175528) --- homeassistant/components/history_stats/data.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 1f132870af14..76ed224b5f78 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -197,8 +197,7 @@ class HistoryStats: finally: self._query_count -= 1 self._history_current_period = [ - HistoryState(state.state, state.last_changed.timestamp()) - for state in states + HistoryState(state.state, state.last_changed_timestamp) for state in states ] def _state_changes_during_period( From 7043ec06ce13279cf2614d7eb5b74cf902adb0d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 12:30:17 +0200 Subject: [PATCH 345/707] Have the Assist API get its tools and prompt from the llm integration (#175659) Co-authored-by: Claude Opus 4.8 --- .../components/conversation/manifest.json | 2 +- homeassistant/components/homeassistant/llm.py | 53 +++- homeassistant/components/intent/llm.py | 62 ++++- homeassistant/components/llm/__init__.py | 46 +++- homeassistant/helpers/llm.py | 243 +----------------- tests/components/ai_task/test_task.py | 5 +- .../snapshots/test_conversation.ambr | 2 +- .../components/anthropic/test_conversation.py | 13 +- .../assist_pipeline/test_pipeline.py | 6 +- .../cloud/snapshots/test_http_api.ambr | 6 +- .../components/conversation/test_chat_log.py | 136 +++++----- tests/components/homeassistant/test_llm.py | 16 ++ tests/components/intent/test_llm.py | 17 ++ tests/components/ollama/test_conversation.py | 9 +- tests/helpers/test_llm.py | 122 +++------ tests/snapshots/test_bootstrap.ambr | 2 + 16 files changed, 314 insertions(+), 426 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 536abf5b3dd5..b5466dfb5f3e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "conversation", "name": "Conversation", "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"], - "dependencies": ["http", "intent"], + "dependencies": ["http", "intent", "llm"], "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index f56df7e31616..70a1f7557efb 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -34,6 +34,36 @@ from .exposed_entities import async_should_expose CALENDAR_DOMAIN = "calendar" SCRIPT_DOMAIN = "script" +DYNAMIC_CONTEXT_PROMPT = ( + "You ARE equipped to answer questions about the" + " current state of\n" + "the home using the `GetLiveContext` tool." + " This is a primary function." + " Do not state you lack the\n" + "functionality if the question requires live data.\n" + "If the user asks about device existence/type" + ' (e.g., "Do I have lights in the bedroom?"):' + " Answer\n" + "from the static context below.\n" + "If the user asks about the CURRENT state, value," + ' or mode (e.g., "Is the lock locked?",\n' + '"Is the fan on?",' + ' "What mode is the thermostat in?",' + ' "What is the temperature outside?"):\n' + " 1. Recognize this requires live data.\n" + " 2. You MUST call `GetLiveContext`." + " This tool will provide the needed real-time" + " information (like temperature from the local" + " weather, lock status, etc.).\n" + " 3. Use the tool's response** to answer the" + " user accurately" + ' (e.g., "The temperature outside is' + ' [value from tool].").\n' + "For general knowledge questions not about the" + " home: Answer truthfully from internal" + " knowledge.\n" +) + @callback def async_get_exposed_entities( @@ -290,10 +320,23 @@ class GetLiveContextTool(Tool): def async_get_tools( hass: HomeAssistant, llm_context: LLMContext, api_id: str ) -> LLMTools | None: - """Return the GetLiveContext tool. - - The tool is always offered; it reports when nothing is exposed at call time. - """ + """Return the GetLiveContext tool and the smart home context prompt.""" if api_id != LLM_API_ASSIST: return None - return LLMTools(tools=[GetLiveContextTool()]) + + exposed_entities = async_get_exposed_entities( + hass, llm_context.assistant, include_state=False + ) + if exposed_entities: + prompt = "\n".join( + [ + DYNAMIC_CONTEXT_PROMPT, + "Static Context: An overview of the areas" + " and the devices in this smart home:", + yaml_util.dump(list(exposed_entities.values())), + ] + ) + else: + prompt = NO_ENTITIES_PROMPT + + return LLMTools(tools=[GetLiveContextTool()], prompt=prompt) diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py index 82b122a91890..082987914327 100644 --- a/homeassistant/components/intent/llm.py +++ b/homeassistant/components/intent/llm.py @@ -8,7 +8,12 @@ exposed by their own integration's ``llm.py`` platform. from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import intent +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + floor_registry as fr, + intent, +) from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool from .timers import async_device_supports_timers @@ -33,19 +38,28 @@ TIMER_INTENTS = ( intent.INTENT_TIMER_STATUS, ) +DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( + "When controlling Home Assistant always call the intent tools. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "When controlling a device, prefer passing just name and domain. " + "When controlling an area, prefer passing just area name and domain." +) + @callback def async_get_tools( hass: HomeAssistant, llm_context: LLMContext, api_id: str ) -> LLMTools | None: - """Return LLM tools for the generic intents.""" + """Return the generic intent tools and the device control prompt.""" if api_id != LLM_API_ASSIST: return None + supports_timers = ( + llm_context.device_id is not None + and async_device_supports_timers(hass, llm_context.device_id) + ) wanted = set(LLM_INTENTS) - if llm_context.device_id and async_device_supports_timers( - hass, llm_context.device_id - ): + if supports_timers: wanted.update(TIMER_INTENTS) exposed_domains = { @@ -65,4 +79,40 @@ def async_get_tools( ] if not tools: return None - return LLMTools(tools=tools) + + # Only guide device control once something is exposed to control. + if not exposed_domains: + return LLMTools(tools=tools) + + # Tell the voice satellite which area it is in so generic commands target it. + floor: fr.FloorEntry | None = None + area: ar.AreaEntry | None = None + if llm_context.device_id and ( + device := dr.async_get(hass).async_get(llm_context.device_id) + ): + area_reg = ar.async_get(hass) + if device.area_id and (area := area_reg.async_get_area(device.area_id)): + if area.floor_id: + floor = fr.async_get(hass).async_get_floor(area.floor_id) + + if area and floor: + area_prompt = ( + f"You are in area {area.name} (floor {floor.name}) and all generic" + " commands like 'turn on the lights' should target this area." + ) + elif area: + area_prompt = ( + f"You are in area {area.name} and all generic commands like" + " 'turn on the lights' should target this area." + ) + else: + area_prompt = ( + "When a user asks to turn on all devices of a specific type, " + "ask the user to specify an area, unless there is only one device" + " of that type." + ) + + prompt_parts = [DEVICE_CONTROL_TOOL_USAGE_PROMPT, area_prompt] + if not supports_timers: + prompt_parts.append("This device is not able to start timers.") + return LLMTools(tools=tools, prompt="\n".join(prompt_parts)) diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py index 3f8d87eb2a05..7b907762f89c 100644 --- a/homeassistant/components/llm/__init__.py +++ b/homeassistant/components/llm/__init__.py @@ -1,19 +1,21 @@ -"""The LLM integration. - -Owns the LLM tools platform: integrations contribute tools to the LLM APIs -through an ``/llm.py`` platform with an ``async_get_tools`` hook. -The platforms are loaded lazily and queried per request. The framework -(``Tool``, the APIs) lives in ``homeassistant.helpers.llm``. -""" +"""The LLM integration.""" from dataclasses import dataclass import logging -from typing import Protocol +from typing import Protocol, override from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms -from homeassistant.helpers.llm import LLMContext, Tool +from homeassistant.helpers.llm import ( + API, + LLM_API_ASSIST, + APIInstance, + LLMContext, + Tool, + async_register_api, + selector_serializer, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -54,6 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_PLATFORMS] = LazyIntegrationPlatforms( hass, DOMAIN, _process_llm_tools_platform ) + async_register_api(hass, AssistAPI(hass)) return True @@ -86,3 +89,28 @@ async def async_get_tools( if result.prompt: prompts.append(result.prompt) return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None) + + +class AssistAPI(API): + """API exposing Assist API to LLMs.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the class.""" + super().__init__( + hass=hass, + id=LLM_API_ASSIST, + name="Assist", + ) + + @override + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + llm_tools = await async_get_tools(self.hass, llm_context, self.id) + + return APIInstance( + api=self, + api_prompt=llm_tools.prompt or "", + llm_context=llm_context, + tools=llm_tools.tools, + custom_serializer=selector_serializer, + ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 1b5f509b6612..8a23488b7435 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -6,7 +6,6 @@ from dataclasses import dataclass, field as dc_field from datetime import timedelta from decimal import Decimal from enum import Enum -from functools import cache, partial from operator import attrgetter from typing import Any, cast, override @@ -18,13 +17,10 @@ from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose -from homeassistant.components.intent import async_device_supports_timers from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.sensor import async_rounded_state from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices -from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, ATTR_SERVICE, @@ -73,43 +69,6 @@ NO_ENTITIES_PROMPT = ( "to their voice assistant in Home Assistant." ) -DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( - "When controlling Home Assistant always call the intent tools. " - "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " - "When controlling a device, prefer passing just name and domain. " - "When controlling an area, prefer passing just area name and domain." -) - -DYNAMIC_CONTEXT_PROMPT = ( - "You ARE equipped to answer questions about the" - " current state of\n" - "the home using the `GetLiveContext` tool." - " This is a primary function." - " Do not state you lack the\n" - "functionality if the question requires live data.\n" - "If the user asks about device existence/type" - ' (e.g., "Do I have lights in the bedroom?"):' - " Answer\n" - "from the static context below.\n" - "If the user asks about the CURRENT state, value," - ' or mode (e.g., "Is the lock locked?",\n' - '"Is the fan on?",' - ' "What mode is the thermostat in?",' - ' "What is the temperature outside?"):\n' - " 1. Recognize this requires live data.\n" - " 2. You MUST call `GetLiveContext`." - " This tool will provide the needed real-time" - " information (like temperature from the local" - " weather, lock status, etc.).\n" - " 3. Use the tool's response** to answer the" - " user accurately" - ' (e.g., "The temperature outside is' - ' [value from tool].").\n' - "For general knowledge questions not about the" - " home: Answer truthfully from internal" - " knowledge.\n" -) - @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: @@ -123,10 +82,12 @@ def async_render_no_api_prompt(hass: HomeAssistant) -> str: @singleton("llm") @callback def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: - """Get all the LLM APIs.""" - return { - LLM_API_ASSIST: AssistAPI(hass=hass), - } + """Return the registry of LLM APIs. + + APIs are registered by their owning integration; the Assist API is + registered by the ``llm`` integration during setup. + """ + return {} @callback @@ -459,198 +420,6 @@ class MergedAPI(API): return merged -class AssistAPI(API): - """API exposing Assist API to LLMs.""" - - IGNORE_INTENTS = { - intent.INTENT_GET_TEMPERATURE, - INTENT_GET_WEATHER, - INTENT_OPEN_COVER, # deprecated - INTENT_CLOSE_COVER, # deprecated - intent.INTENT_GET_STATE, - intent.INTENT_NEVERMIND, - intent.INTENT_TOGGLE, - intent.INTENT_GET_CURRENT_DATE, - intent.INTENT_GET_CURRENT_TIME, - intent.INTENT_RESPOND, - } - - def __init__(self, hass: HomeAssistant) -> None: - """Init the class.""" - super().__init__( - hass=hass, - id=LLM_API_ASSIST, - name="Assist", - ) - self.cached_slugify = cache( - partial(unicode_slug.slugify, separator="_", lowercase=False) - ) - - @override - async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: - """Return the instance of the API.""" - if llm_context.assistant: - exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant, include_state=False - ) - else: - exposed_entities = None - - return APIInstance( - api=self, - api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), - llm_context=llm_context, - tools=self._async_get_tools(llm_context, exposed_entities), - custom_serializer=selector_serializer, - ) - - @callback - def _async_get_api_prompt( - self, llm_context: LLMContext, exposed_entities: dict | None - ) -> str: - if not exposed_entities or not exposed_entities["entities"]: - return NO_ENTITIES_PROMPT - - # Collect all parts, filtering out any None values - prompt_parts = [ - DEVICE_CONTROL_TOOL_USAGE_PROMPT, - DYNAMIC_CONTEXT_PROMPT, - *self._async_get_exposed_entities_prompt(exposed_entities), - self._async_get_voice_satellite_area_prompt(llm_context), - self._async_get_no_timer_prompt(llm_context), - ] - - # Filter out None and empty strings before joining - return "\n".join([part for part in prompt_parts if part]) - - @callback - def _async_get_no_timer_prompt(self, llm_context: LLMContext) -> str | None: - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - return "This device is not able to start timers." - return None - - @callback - def _async_get_voice_satellite_area_prompt(self, llm_context: LLMContext) -> str: - """Return the area prompt for the voice satellite.""" - floor: fr.FloorEntry | None = None - area: ar.AreaEntry | None = None - extra = "" - if llm_context.device_id: - device_reg = dr.async_get(self.hass) - device = device_reg.async_get(llm_context.device_id) - - if device: - area_reg = ar.async_get(self.hass) - if device.area_id and (area := area_reg.async_get_area(device.area_id)): - floor_reg = fr.async_get(self.hass) - if area.floor_id: - floor = floor_reg.async_get_floor(area.floor_id) - - extra = ( - "and all generic commands like" - " 'turn on the lights' should target" - " this area." - ) - - if floor and area: - return f"You are in area {area.name} (floor {floor.name}) {extra}".strip() - if area: - return f"You are in area {area.name} {extra}".strip() - return ( - "When a user asks to turn on all devices of a specific type, " - "ask the user to specify an area, unless there" - " is only one device of that type." - ) - - @callback - def _async_get_exposed_entities_prompt( - self, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the API for exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "Static Context: An overview of the areas" - " and the devices in this smart home:" - ) - prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) - - return prompt - - @callback - def _async_get_tools( - self, llm_context: LLMContext, exposed_entities: dict | None - ) -> list[Tool]: - """Return a list of LLM tools.""" - ignore_intents = self.IGNORE_INTENTS - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - ignore_intents = ignore_intents | { - intent.INTENT_START_TIMER, - intent.INTENT_CANCEL_TIMER, - intent.INTENT_INCREASE_TIMER, - intent.INTENT_DECREASE_TIMER, - intent.INTENT_PAUSE_TIMER, - intent.INTENT_UNPAUSE_TIMER, - intent.INTENT_TIMER_STATUS, - } - - intent_handlers = [ - intent_handler - for intent_handler in intent.async_get(self.hass) - if intent_handler.intent_type not in ignore_intents - ] - - exposed_domains: set[str] | None = None - if exposed_entities is not None: - exposed_domains = { - info["domain"] for info in exposed_entities["entities"].values() - } - - intent_handlers = [ - intent_handler - for intent_handler in intent_handlers - if intent_handler.platforms is None - or intent_handler.platforms & exposed_domains - ] - - tools: list[Tool] = [ - IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) - for intent_handler in intent_handlers - ] - - tools.append(GetDateTimeTool()) - - if exposed_entities: - if exposed_entities[CALENDAR_DOMAIN]: - names = [] - for info in exposed_entities[CALENDAR_DOMAIN].values(): - names.extend(info["names"].split(", ")) - tools.append(CalendarGetEventsTool(names)) - - if exposed_domains is not None and TODO_DOMAIN in exposed_domains: - names = [] - for info in exposed_entities["entities"].values(): - if info["domain"] != TODO_DOMAIN: - continue - names.extend(info["names"].split(", ")) - tools.append(TodoGetItemsTool(names)) - - tools.extend( - ScriptTool(self.hass, script_entity_id) - for script_entity_id in exposed_entities[SCRIPT_DOMAIN] - ) - - if exposed_domains: - tools.append(GetLiveContextTool()) - - return tools - - def _get_exposed_entities( hass: HomeAssistant, assistant: str, diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index a15cec0b466c..2a5add49a9bc 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -16,10 +16,11 @@ from homeassistant.components.ai_task import ( from homeassistant.components.ai_task.const import DATA_MEDIA_SOURCE from homeassistant.components.camera import Image from homeassistant.components.conversation import async_get_chat_log +from homeassistant.components.llm import AssistAPI from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, llm +from homeassistant.helpers import chat_session from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -77,7 +78,7 @@ async def test_generate_data_preferred_entity( assert state is not None assert state.state == STATE_UNKNOWN - llm_api = llm.AssistAPI(hass) + llm_api = AssistAPI(hass) result = await async_generate_data( hass, task_name="Test Task", diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index fae45df65f99..bc2af2e73473 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -380,7 +380,7 @@ You are a voice assistant for Home Assistant. Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + Current time is 16:00:00. Today's date is 2024-06-03. ''', 'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc), diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 679b857cad84..2a0188c4bf1d 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -64,6 +64,7 @@ from homeassistant.components.anthropic.entity import ( ) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler +from homeassistant.components.llm import LLMTools from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -324,7 +325,7 @@ async def test_prompt_caching_automatic( assert isinstance(system, str) -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) @pytest.mark.parametrize( ("tool_call_json_parts", "expected_call_tool_args"), [ @@ -361,7 +362,7 @@ async def test_function_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -421,7 +422,7 @@ async def test_function_call( ) -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -441,7 +442,7 @@ async def test_function_exception( ) mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -852,7 +853,7 @@ async def test_redacted_thinking( assert chat_log.content[1:] == snapshot -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_extended_thinking_tool_call( mock_get_tools, hass: HomeAssistant, @@ -884,7 +885,7 @@ async def test_extended_thinking_tool_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index ff4430613080..3a85803d7219 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -40,6 +40,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_get_pipelines, async_update_pipeline, ) +from homeassistant.components.llm import LLMTools from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import ( @@ -1822,8 +1823,9 @@ async def test_chat_log_tts_streaming( with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", - return_value=[mock_tool], + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), ), patch( "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index e1cf19020867..72b13befc4ce 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -21,7 +21,7 @@ ## Active Integrations - Built-in integrations: 22 + Built-in integrations: 23 Custom integrations: 1
Built-in integrations @@ -42,6 +42,7 @@ homeassistant | Home Assistant Core Integration http | HTTP intent | Intent + llm | LLM media_source | Media Source mock_no_info_integration | mock_no_info_integration repairs | Repairs @@ -156,7 +157,7 @@ ## Active Integrations - Built-in integrations: 22 + Built-in integrations: 23 Custom integrations: 0
Built-in integrations @@ -177,6 +178,7 @@ homeassistant | Home Assistant Core Integration http | HTTP intent | Intent + llm | LLM media_source | Media Source mock_no_info_integration | mock_no_info_integration repairs | Repairs diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 164a6aafe510..1af92459d047 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -25,14 +25,22 @@ from homeassistant.components.conversation.chat_log import ( ChatLogEventType, async_subscribe_chat_logs, ) +from homeassistant.components.llm import LLMTools from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, llm +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed +@pytest.fixture(autouse=True) +async def setup_llm(hass: HomeAssistant) -> None: + """Set up the llm integration so the Assist API can pull its tools.""" + assert await async_setup_component(hass, "llm", {}) + + async def test_cleanup( hass: HomeAssistant, mock_conversation_input: ConversationInput, @@ -434,67 +442,67 @@ async def test_tool_call( ) mock_tool.async_call.return_value = "Test response" - with patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools: - mock_get_tools.return_value = [mock_tool] + with ( + patch( + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + content = AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + ], + ) - with ( - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session, mock_conversation_input) as chat_log, - ): - await chat_log.async_provide_llm_data( - mock_conversation_input.as_llm_context("test"), - user_llm_hass_api="assist", - user_llm_prompt=None, + tool_call_tasks = { + tool_call_id: hass.async_create_task( + chat_log.llm_api.async_call_tool(content.tool_calls[0]), + tool_call_id, ) - content = AssistantContent( - agent_id=mock_conversation_input.agent_id, - content="", - tool_calls=[ - llm.ToolInput( - id="mock-tool-call-id", - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ), - llm.ToolInput( - id="mock-tool-call-id-2", - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ), - ], + for tool_call_id in prerun_tool_tasks + } + + with pytest.raises(ValueError): + chat_log.async_add_assistant_content_without_tools(content) + + results = [ + tool_result_content + async for tool_result_content in chat_log.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks or None ) + ] - tool_call_tasks = { - tool_call_id: hass.async_create_task( - chat_log.llm_api.async_call_tool(content.tool_calls[0]), - tool_call_id, - ) - for tool_call_id in prerun_tool_tasks - } - - with pytest.raises(ValueError): - chat_log.async_add_assistant_content_without_tools(content) - - results = [ - tool_result_content - async for tool_result_content in chat_log.async_add_assistant_content( - content, tool_call_tasks=tool_call_tasks or None - ) - ] - - assert results[0] == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id", - tool_result="Test response", - tool_name="test_tool", - ) - assert results[1] == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id-2", - tool_result="Test response", - tool_name="test_tool", - ) + assert results[0] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) + assert results[1] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id-2", + tool_result="Test response", + tool_name="test_tool", + ) @freeze_time("2025-10-31 12:00:00") @@ -514,12 +522,13 @@ async def test_tool_call_exception( with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools, + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - mock_get_tools.return_value = [mock_tool] await chat_log.async_provide_llm_data( mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", @@ -712,8 +721,10 @@ async def test_add_delta_content_stream( with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools, + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), chat_session.async_get_chat_session(hass) as session, async_get_chat_log( hass, @@ -724,7 +735,6 @@ async def test_add_delta_content_stream( ), ) as chat_log, ): - mock_get_tools.return_value = [mock_tool] await chat_log.async_provide_llm_data( mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index 2d888f08c6ed..019a44543f4f 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -53,6 +53,22 @@ async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: assert ha_llm.async_get_tools(hass, _llm_context(), "other") is None +async def test_prompt_includes_context(hass: HomeAssistant) -> None: + """Test the platform contributes the live-context and static-overview prompt.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert result.prompt is not None + assert ha_llm.DYNAMIC_CONTEXT_PROMPT in result.prompt + assert "Static Context:" in result.prompt + assert "Kitchen Light" in result.prompt + + +async def test_prompt_no_entities(hass: HomeAssistant) -> None: + """Test the platform contributes the no-entities prompt when nothing is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert result.prompt == llm.NO_ENTITIES_PROMPT + + async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None: """Test GetLiveContext reports an error when nothing is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) diff --git a/tests/components/intent/test_llm.py b/tests/components/intent/test_llm.py index 6db25c1b6ab1..e3b5a79764b4 100644 --- a/tests/components/intent/test_llm.py +++ b/tests/components/intent/test_llm.py @@ -78,6 +78,23 @@ async def test_set_position_requires_exposed_cover(hass: HomeAssistant) -> None: assert "HassSetPosition" not in await _tool_names(hass) +async def test_prompt_includes_device_control(hass: HomeAssistant) -> None: + """Test the platform contributes device-control guidance when exposed.""" + result = intent_llm.async_get_tools(hass, _llm_context(), "assist") + assert result is not None + assert result.prompt is not None + assert intent_llm.DEVICE_CONTROL_TOOL_USAGE_PROMPT in result.prompt + assert "This device is not able to start timers." in result.prompt + + +async def test_no_prompt_without_exposed_entities(hass: HomeAssistant) -> None: + """Test the platform contributes no prompt when nothing is exposed.""" + async_expose_entity(hass, "conversation", COVER_ENTITY_ID, False) + result = intent_llm.async_get_tools(hass, _llm_context(), "assist") + assert result is not None + assert result.prompt is None + + async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: """Test the platform returns None for an unsupported API.""" assert intent_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 530210e7f324..0644a9faa891 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components import conversation, ollama from homeassistant.components.conversation import trace +from homeassistant.components.llm import LLMTools from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -292,7 +293,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -314,7 +315,7 @@ async def test_function_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) def completion_result(*args, messages, **kwargs): for message in messages: @@ -379,7 +380,7 @@ async def test_function_call( ) -@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -398,7 +399,7 @@ async def test_function_exception( ) mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) def completion_result(*args, messages, **kwargs): for message in messages: diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index bf363d695600..a7606984d004 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -31,6 +31,12 @@ from homeassistant.util.json import JsonObjectType from tests.common import MockConfigEntry, async_mock_service +@pytest.fixture(autouse=True) +async def setup_llm(hass: HomeAssistant) -> None: + """Set up the llm integration so the Assist API can pull tools from it.""" + assert await async_setup_component(hass, "llm", {}) + + @pytest.fixture def llm_context() -> llm.LLMContext: """Return tool input context.""" @@ -142,22 +148,13 @@ async def test_call_tool_no_existing( async def test_assist_api( hass: HomeAssistant, - entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, floor_registry: fr.FloorRegistry, ) -> None: - """Test Assist API.""" + """Test calling an IntentTool through the Assist API.""" assert await async_setup_component(hass, "homeassistant", {}) - entity_registry.async_get_or_create( - "light", - "kitchen", - "mock-id-kitchen", - original_name="Kitchen", - suggested_object_id="kitchen", - ).write_unavailable_state(hass) - test_context = Context() llm_context = llm.LLMContext( platform="test_platform", @@ -176,32 +173,10 @@ async def test_assist_api( class MyIntentHandler(intent.IntentHandler): intent_type = "test_intent" slot_schema = schema - platforms = set() # Match none intent_handler = MyIntentHandler() - intent.async_register(hass, intent_handler) - - assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["GetDateTime", "GetLiveContext"] - - # Match all - intent_handler.platforms = None - - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == [ - "test_intent", - "GetDateTime", - "GetLiveContext", - ] - - # Match specific domain - intent_handler.platforms = {"light"} - - api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 3 - tool = api.tools[0] + tool = llm.IntentTool("test_intent", intent_handler) assert tool.name == "test_intent" assert tool.description == "Execute Home Assistant test_intent intent" assert tool.parameters == vol.Schema( @@ -213,6 +188,14 @@ async def test_assist_api( ) assert str(tool) == "" + api = next(api for api in llm.async_get_apis(hass) if api.id == "assist") + instance = llm.APIInstance( + api=api, + api_prompt="", + llm_context=llm_context, + tools=[tool], + ) + assert test_context.json_fragment # To reproduce an error case in tracing intent_response = intent.IntentResponse("*") intent_response.async_set_states( @@ -230,7 +213,7 @@ async def test_assist_api( with patch( "homeassistant.helpers.intent.async_handle", return_value=intent_response ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) + response = await instance.async_call_tool(tool_input) mock_intent_handle.assert_awaited_once_with( hass=hass, @@ -286,7 +269,7 @@ async def test_assist_api( with patch( "homeassistant.helpers.intent.async_handle", return_value=intent_response ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) + response = await instance.async_call_tool(tool_input) mock_intent_handle.assert_awaited_once_with( hass=hass, @@ -346,55 +329,16 @@ async def test_assist_api_get_timer_tools( assert "HassStartTimer" in [tool.name for tool in api.tools] -async def test_assist_api_tools( - hass: HomeAssistant, llm_context: llm.LLMContext -) -> None: - """Test getting timer tools with Assist API.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) - - llm_context.device_id = "test_device" - - async_register_timer_handler(hass, "test_device", lambda *args: None) - - class MyIntentHandler(intent.IntentHandler): - intent_type = "Super crazy intent with unique nåme" - description = "my intent handler" - - intent.async_register(hass, MyIntentHandler()) - - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == [ - "HassTurnOn", - "HassTurnOff", - "HassStartTimer", - "HassCancelTimer", - "HassCancelAllTimers", - "HassIncreaseTimer", - "HassDecreaseTimer", - "HassPauseTimer", - "HassUnpauseTimer", - "HassTimerStatus", - "Super_crazy_intent_with_unique_name", - "GetDateTime", - ] - - async def test_assist_api_description( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: - """Test intent description with Assist API.""" + """Test that the intent handler description is used for the tool.""" class MyIntentHandler(intent.IntentHandler): intent_type = "test_intent" description = "my intent handler" - intent.async_register(hass, MyIntentHandler()) - - assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 2 - tool = api.tools[0] + tool = llm.IntentTool("test_intent", MyIntentHandler()) assert tool.name == "test_intent" assert tool.description == "my intent handler" @@ -767,9 +711,9 @@ Static Context: An overview of the areas and the devices in this smart home: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -792,9 +736,9 @@ Static Context: An overview of the areas and the devices in this smart home: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -809,9 +753,9 @@ Static Context: An overview of the areas and the devices in this smart home: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -822,9 +766,9 @@ Static Context: An overview of the areas and the devices in this smart home: api = await llm.async_get_api(hass, "assist", llm_context) # The no_timer_prompt is gone assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt}""" ) @@ -1232,7 +1176,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 2 tool = tools[0] @@ -1356,7 +1300,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 2 tool = tools[0] @@ -1412,7 +1356,7 @@ async def test_script_tool_name(hass: HomeAssistant) -> None: api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 1 tool = tools[0] @@ -1697,6 +1641,7 @@ async def test_selector_serializer( async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: """Test the calendar get events tool.""" assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "calendar", {}) hass.states.async_set( "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} ) @@ -1953,7 +1898,8 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["GetDateTime"] + # GetLiveContext is always offered; it reports when nothing is exposed. + assert [tool.name for tool in api.tools] == ["GetLiveContext", "GetDateTime"] async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index e50a244d7895..561c4a060845 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -61,6 +61,7 @@ 'labs', 'lawn_mower', 'light', + 'llm', 'lock', 'logger', 'lovelace', @@ -169,6 +170,7 @@ 'labs', 'lawn_mower', 'light', + 'llm', 'lock', 'logger', 'lovelace', From 0145096dd770ab22222d67de4bd047dc5bdc1170 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Thu, 9 Jul 2026 12:30:32 +0200 Subject: [PATCH 346/707] Fix HmIP-FLC door opener button to work for non-admin clients (#171060) --- .../components/homematicip_cloud/button.py | 38 ++++++++--- .../components/homematicip_cloud/conftest.py | 11 ++++ .../homematicip_cloud/test_button.py | 63 ++++++++++++++++--- 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index ae948ce5b4c9..3a017690ee26 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -2,6 +2,7 @@ from typing import override +from homematicip.base.functionalChannels import AccessAuthorizationChannel from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity @@ -12,11 +13,17 @@ from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP -def _is_full_flush_lock_controller(device: object) -> bool: - """Return whether the device is an HmIP-FLC.""" - return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( - device, "send_start_impulse_async" - ) +def _door_opener_authorization_channel( + device: object, +) -> AccessAuthorizationChannel | None: + """Return the AccessAuthorizationChannel routed to the door opener.""" + for channel in getattr(device, "functionalChannels", []): + if ( + isinstance(channel, AccessAuthorizationChannel) + and getattr(channel, "channelRole", None) == "DOOR_OPENER_ACTUATOR" + ): + return channel + return None async def async_setup_entry( @@ -33,9 +40,10 @@ async def async_setup_entry( if isinstance(device, WallMountedGarageDoorController) ] entities.extend( - HomematicipFullFlushLockControllerButton(hap, device) + HomematicipFullFlushLockControllerButton(hap, device, auth_channel) for device in hap.home.devices - if _is_full_flush_lock_controller(device) + if getattr(device, "modelType", None) == "HmIP-FLC" + and (auth_channel := _door_opener_authorization_channel(device)) is not None ) async_add_entities(entities) @@ -57,14 +65,24 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity): """Representation of the HomematicIP full flush lock controller opener.""" - def __init__(self, hap: HomematicipHAP, device) -> None: + def __init__( + self, + hap: HomematicipHAP, + device, + auth_channel: AccessAuthorizationChannel, + ) -> None: """Initialize the full flush lock controller opener button.""" super().__init__( hap, device, post="Door opener", feature_id="lock_opener_button" ) self._attr_icon = "mdi:door-open" + self._auth_channel = auth_channel @override async def async_press(self) -> None: - """Handle the button press.""" - await self._device.send_start_impulse_async() + """Pull the latch via the access-authorization channel. + + This is the only path non-admin clients may use; the door-switch + channel rejects them with CLIENT_ACCESS_DENIED. + """ + await self._auth_channel.async_pull_latch() diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 26e359422d0b..6d882f5e2a47 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -171,6 +171,17 @@ def full_flush_lock_controller_device_data_fixture() -> dict[str, Any]: "label": "", "supportedOptionalFeatures": {}, }, + "9": { + "authorized": True, + "channelRole": "DOOR_OPENER_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 4, + "groups": [], + "index": 9, + "label": "", + "supportedOptionalFeatures": {}, + }, }, "homeId": "00000000-0000-0000-0000-000000000001", "id": "3014F7110000000000000026", diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index a1eb06a88617..7ea135c1a316 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,6 +1,7 @@ """Tests for HomematicIP Cloud button.""" from typing import Any +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory @@ -51,7 +52,14 @@ async def test_hmip_full_flush_lock_controller_button( default_mock_hap_factory: HomeFactory, full_flush_lock_controller_device_data: dict[str, Any], ) -> None: - """Test HomematicIP full flush lock controller opener button.""" + """Test HomematicIP full flush lock controller opener button. + + The button must pull the latch on the ACCESS_AUTHORIZATION_CHANNEL with + role DOOR_OPENER_ACTUATOR (channel 9 in the fixture), not call + send_start_impulse on the underlying DOOR_SWITCH_CHANNEL. The former is + the only endpoint non-admin clients are allowed to invoke; the latter + fails with CLIENT_ACCESS_DENIED for non-admin clients. + """ entity_id = "button.universal_motorschloss_controller_door_opener" entity_name = "Universal Motorschloss Controller Door opener" device_model = "HmIP-FLC" @@ -66,18 +74,53 @@ async def test_hmip_full_flush_lock_controller_button( assert state assert state.state == STATE_UNKNOWN - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - freezer.move_to(now) - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] + auth_channel = next( + ch + for ch in hmip_device.functionalChannels + if ch.functionalChannelType.name == "ACCESS_AUTHORIZATION_CHANNEL" + and ch.channelRole == "DOOR_OPENER_ACTUATOR" ) - hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] - assert hmip_device.mock_calls[-1][0] == "send_start_impulse_async" + with ( + patch.object( + auth_channel, "async_pull_latch", new_callable=AsyncMock + ) as mock_pull_latch, + patch.object( + hmip_device, "send_start_impulse_async", new_callable=AsyncMock + ) as mock_send_start_impulse, + ): + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_pull_latch.assert_awaited_once_with() + mock_send_start_impulse.assert_not_awaited() state = hass.states.get(entity_id) assert state assert state.state == now.isoformat() + + +async def test_hmip_full_flush_lock_controller_button_missing_channel( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], +) -> None: + """Button is not created when the door-opener auth channel is missing.""" + # Strip the access-authorization channel for DOOR_OPENER_ACTUATOR so the + # setup detection function rejects the device. + full_flush_lock_controller_device_data["functionalChannels"].pop("9") + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universal Motorschloss Controller"], + extra_devices=[full_flush_lock_controller_device_data], + ) + + entity_id = "button.universal_motorschloss_controller_door_opener" + assert hass.states.get(entity_id) is None + assert entity_id not in mock_hap.hmip_device_by_entity_id From b0ed4154ece65feb811dc02f2977f3fdef496179 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 12:31:31 +0200 Subject: [PATCH 347/707] Move ecobee services to async_setup (#175480) --- homeassistant/components/ecobee/__init__.py | 11 ++ homeassistant/components/ecobee/climate.py | 174 ++----------------- homeassistant/components/ecobee/services.py | 178 ++++++++++++++++++++ 3 files changed, 204 insertions(+), 159 deletions(-) create mode 100644 homeassistant/components/ecobee/services.py diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index e7462b40143a..1b0a4c031143 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -22,15 +22,26 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import _LOGGER, CONF_REFRESH_TOKEN, DOMAIN, PLATFORMS +from .services import async_setup_services MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + type EcobeeConfigEntry = ConfigEntry[EcobeeData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ecobee integration.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data.get(CONF_API_KEY) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 81902a2cd2cd..dcc1e8f38416 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -20,7 +20,6 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, @@ -28,7 +27,7 @@ from homeassistant.const import ( STATE_ON, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -49,18 +48,20 @@ from .const import ( ECOBEE_MODEL_TO_NAME, MANUFACTURER, ) -from .util import ecobee_date, ecobee_time, is_indefinite_hold +from .services import ( + ATTR_COOL_TEMP, + ATTR_END_DATE, + ATTR_END_TIME, + ATTR_FAN_MIN_ON_TIME, + ATTR_FAN_MODE, + ATTR_HEAT_TEMP, + ATTR_START_DATE, + ATTR_START_TIME, + ATTR_VACATION_NAME, + _async_get_thermostats, +) +from .util import is_indefinite_hold -ATTR_COOL_TEMP = "cool_temp" -ATTR_END_DATE = "end_date" -ATTR_END_TIME = "end_time" -ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" -ATTR_FAN_MODE = "fan_mode" -ATTR_HEAT_TEMP = "heat_temp" -ATTR_RESUME_ALL = "resume_all" -ATTR_START_DATE = "start_date" -ATTR_START_TIME = "start_time" -ATTR_VACATION_NAME = "vacation_name" ATTR_DST_ENABLED = "dst_enabled" ATTR_MIC_ENABLED = "mic_enabled" ATTR_AUTO_AWAY = "auto_away" @@ -68,7 +69,6 @@ ATTR_FOLLOW_ME = "follow_me" ATTR_SENSOR_LIST = "device_ids" ATTR_PRESET_MODE = "preset_mode" -DEFAULT_RESUME_ALL = False PRESET_AWAY_INDEFINITELY = "away_indefinitely" PRESET_TEMPERATURE = "temp" PRESET_VACATION = "vacation" @@ -127,69 +127,11 @@ PRESET_TO_ECOBEE_HOLD = { PRESET_HOLD_INDEFINITE: "indefinite", } -SERVICE_CREATE_VACATION = "create_vacation" -SERVICE_DELETE_VACATION = "delete_vacation" -SERVICE_RESUME_PROGRAM = "resume_program" -SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" SERVICE_SET_DST_MODE = "set_dst_mode" SERVICE_SET_MIC_MODE = "set_mic_mode" SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes" SERVICE_SET_SENSORS_USED_IN_CLIMATE = "set_sensors_used_in_climate" -DTGROUP_START_INCLUSIVE_MSG = ( - f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" -) - -DTGROUP_END_INCLUSIVE_MSG = ( - f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" -) - -CREATE_VACATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), - vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), - vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), - vol.Inclusive( - ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG - ): ecobee_date, - vol.Inclusive( - ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG - ): ecobee_time, - vol.Inclusive( - ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG - ): ecobee_date, - vol.Inclusive( - ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG - ): ecobee_time, - vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), - vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( - int, vol.Range(min=0, max=60) - ), - } -) - -DELETE_VACATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), - } -) - -RESUME_PROGRAM_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, - } -) - -SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), - } -) - SUPPORT_FLAGS = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -226,96 +168,10 @@ async def async_setup_entry( entities.append(Thermostat(data, index, thermostat, hass)) async_add_entities(entities, True) + _async_get_thermostats(hass).extend(entities) platform = entity_platform.async_get_current_platform() - def create_vacation_service(service: ServiceCall) -> None: - """Create a vacation on the target thermostat.""" - entity_id = service.data[ATTR_ENTITY_ID] - - for thermostat in entities: - if thermostat.entity_id == entity_id: - thermostat.create_vacation(service.data) - thermostat.schedule_update_ha_state(True) - break - - def delete_vacation_service(service: ServiceCall) -> None: - """Delete a vacation on the target thermostat.""" - entity_id = service.data[ATTR_ENTITY_ID] - vacation_name = service.data[ATTR_VACATION_NAME] - - for thermostat in entities: - if thermostat.entity_id == entity_id: - thermostat.delete_vacation(vacation_name) - thermostat.schedule_update_ha_state(True) - break - - def fan_min_on_time_set_service(service: ServiceCall) -> None: - """Set the minimum fan on time on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME] - - if entity_id: - target_thermostats = [ - entity for entity in entities if entity.entity_id in entity_id - ] - else: - target_thermostats = entities - - for thermostat in target_thermostats: - thermostat.set_fan_min_on_time(str(fan_min_on_time)) - - thermostat.schedule_update_ha_state(True) - - def resume_program_set_service(service: ServiceCall) -> None: - """Resume the program on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - resume_all = service.data.get(ATTR_RESUME_ALL) - - if entity_id: - target_thermostats = [ - entity for entity in entities if entity.entity_id in entity_id - ] - else: - target_thermostats = entities - - for thermostat in target_thermostats: - thermostat.resume_program(resume_all) - - thermostat.schedule_update_ha_state(True) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_VACATION, - create_vacation_service, - schema=CREATE_VACATION_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_DELETE_VACATION, - delete_vacation_service, - schema=DELETE_VACATION_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_FAN_MIN_ON_TIME, - fan_min_on_time_set_service, - schema=SET_FAN_MIN_ON_TIME_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - resume_program_set_service, - schema=RESUME_PROGRAM_SCHEMA, - ) - platform.async_register_entity_service( SERVICE_SET_DST_MODE, {vol.Required(ATTR_DST_ENABLED): cv.boolean}, diff --git a/homeassistant/components/ecobee/services.py b/homeassistant/components/ecobee/services.py new file mode 100644 index 000000000000..86cdabe32055 --- /dev/null +++ b/homeassistant/components/ecobee/services.py @@ -0,0 +1,178 @@ +"""Services for the ecobee integration.""" + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .util import ecobee_date, ecobee_time + +if TYPE_CHECKING: + from .climate import Thermostat + +ATTR_COOL_TEMP = "cool_temp" +ATTR_END_DATE = "end_date" +ATTR_END_TIME = "end_time" +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +ATTR_FAN_MODE = "fan_mode" +ATTR_HEAT_TEMP = "heat_temp" +ATTR_RESUME_ALL = "resume_all" +ATTR_START_DATE = "start_date" +ATTR_START_TIME = "start_time" +ATTR_VACATION_NAME = "vacation_name" + +DEFAULT_RESUME_ALL = False + +DATA_THERMOSTATS = "thermostats" + +SERVICE_CREATE_VACATION = "create_vacation" +SERVICE_DELETE_VACATION = "delete_vacation" +SERVICE_RESUME_PROGRAM = "resume_program" +SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" + +DTGROUP_START_INCLUSIVE_MSG = ( + f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" +) + +DTGROUP_END_INCLUSIVE_MSG = ( + f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" +) + +CREATE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), + vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), + vol.Inclusive( + ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_time, + vol.Inclusive( + ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_time, + vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), + vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( + int, vol.Range(min=0, max=60) + ), + } +) + +DELETE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + } +) + +RESUME_PROGRAM_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, + } +) + +SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), + } +) + + +@callback +def _async_get_thermostats(hass: HomeAssistant) -> list[Thermostat]: + """Return loaded ecobee thermostat entities.""" + # pylint: disable-next=home-assistant-use-runtime-data + return hass.data[DOMAIN][DATA_THERMOSTATS] + + +def _create_vacation_service(call: ServiceCall) -> None: + """Create a vacation on the target thermostat.""" + for thermostat in _async_get_thermostats(call.hass): + if thermostat.entity_id == call.data[ATTR_ENTITY_ID]: + thermostat.create_vacation(call.data) + thermostat.schedule_update_ha_state(True) + break + + +def _delete_vacation_service(call: ServiceCall) -> None: + """Delete a vacation on the target thermostat.""" + for thermostat in _async_get_thermostats(call.hass): + if thermostat.entity_id == call.data[ATTR_ENTITY_ID]: + thermostat.delete_vacation(call.data[ATTR_VACATION_NAME]) + thermostat.schedule_update_ha_state(True) + break + + +def _fan_min_on_time_set_service(call: ServiceCall) -> None: + """Set the minimum fan on time on the target thermostats.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + thermostats = _async_get_thermostats(call.hass) + if entity_id: + thermostats = [ + thermostat + for thermostat in thermostats + if thermostat.entity_id in entity_id + ] + + for thermostat in thermostats: + thermostat.set_fan_min_on_time(str(call.data[ATTR_FAN_MIN_ON_TIME])) + thermostat.schedule_update_ha_state(True) + + +def _resume_program_set_service(call: ServiceCall) -> None: + """Resume the program on the target thermostats.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + thermostats = _async_get_thermostats(call.hass) + if entity_id: + thermostats = [ + thermostat + for thermostat in thermostats + if thermostat.entity_id in entity_id + ] + + for thermostat in thermostats: + thermostat.resume_program(call.data.get(ATTR_RESUME_ALL)) + thermostat.schedule_update_ha_state(True) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register ecobee services.""" + # pylint: disable-next=home-assistant-use-runtime-data + hass.data.setdefault(DOMAIN, {})[DATA_THERMOSTATS] = [] + + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_VACATION, + _create_vacation_service, + schema=CREATE_VACATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_VACATION, + _delete_vacation_service, + schema=DELETE_VACATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_FAN_MIN_ON_TIME, + _fan_min_on_time_set_service, + schema=SET_FAN_MIN_ON_TIME_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + _resume_program_set_service, + schema=RESUME_PROGRAM_SCHEMA, + ) From 5292eb2846a57294f49e2f4b546a09bba38895a1 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 13:05:28 +0200 Subject: [PATCH 348/707] MELCloud Home add missing test coverage (#176029) --- .../melcloud_home/fixtures/context.json | 2 +- .../snapshots/test_diagnostics.ambr | 2 +- .../melcloud_home/snapshots/test_sensor.ambr | 62 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/tests/components/melcloud_home/fixtures/context.json b/tests/components/melcloud_home/fixtures/context.json index 55a555ffef96..67ec381cdc69 100644 --- a/tests/components/melcloud_home/fixtures/context.json +++ b/tests/components/melcloud_home/fixtures/context.json @@ -32,7 +32,7 @@ "hasAirDirection": true, "hasSwing": true, "hasExtendedTemperatureRange": true, - "hasEnergyConsumedMeter": false, + "hasEnergyConsumedMeter": true, "numberOfFanSpeeds": 5, "minTempCool": 16, "maxTempCool": 31, diff --git a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr index 0cf362c7bf11..950bf9dc298a 100644 --- a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr @@ -33,7 +33,7 @@ 'has_auto_operation_mode': True, 'has_cool_operation_mode': True, 'has_dry_operation_mode': True, - 'has_energy_consumed_meter': False, + 'has_energy_consumed_meter': True, 'has_fan_operation_mode': None, 'has_half_degree_increments': True, 'has_outdoor_temperature_sensor': False, diff --git a/tests/components/melcloud_home/snapshots/test_sensor.ambr b/tests/components/melcloud_home/snapshots/test_sensor.ambr index c2952d0ec309..755967d4fe20 100644 --- a/tests/components/melcloud_home/snapshots/test_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_sensor.ambr @@ -290,6 +290,68 @@ 'state': '21.0', }) # --- +# name: test_all_entities[sensor.living_room_ac_energy_consumed_monthly-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_ac_energy_consumed_monthly', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed (monthly)', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy consumed (monthly)', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'ata-unit-uuid-1_energy_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.living_room_ac_energy_consumed_monthly-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Living Room AC Energy consumed (monthly)', + : '2026-06-01T00:00:00+00:00', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_ac_energy_consumed_monthly', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4505', + }) +# --- # name: test_all_entities[sensor.living_room_ac_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 73b065ae6f486062bd3a1152794b19e0a1925cb9 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:13:36 +0800 Subject: [PATCH 349/707] Switchbot Cloud: Enable Webhook for Smart Lock (#175925) --- homeassistant/components/switchbot_cloud/__init__.py | 2 -- homeassistant/components/switchbot_cloud/const.py | 6 ++++++ homeassistant/components/switchbot_cloud/lock.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index a38b0335165e..81fe526a2938 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -187,10 +187,8 @@ async def make_device_data( devices_data.vacuums.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ - "Smart Lock", "Smart Lock Lite", "Smart Lock Pro", - "Smart Lock Ultra", "Smart Lock Vision", "Smart Lock Vision Pro", "Smart Lock Pro Wifi", diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 61de4193ac54..953f303cc7f5 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -124,6 +124,12 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = { "WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "Smart Lock": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR, Platform.LOCK) + ), + "Smart Lock Ultra": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), "MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "AI Art Frame": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BUTTON, Platform.IMAGE) diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index bb06518111b7..e2e93f0518cc 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -44,7 +44,7 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: - self._attr_is_locked = coord_data["lockState"] == "locked" + self._attr_is_locked = coord_data["lockState"].lower() == "locked" if self.__model != "Smart Lock Lite": self._attr_supported_features = LockEntityFeature.OPEN From 33d8a2adb4a0381f03f55e832a18943d5567c309 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 13:43:57 +0200 Subject: [PATCH 350/707] MELCloud Home add number platform (#174552) --- .../components/melcloud_home/__init__.py | 1 + .../components/melcloud_home/icons.json | 14 + .../components/melcloud_home/number.py | 426 +++++++++++++++ .../components/melcloud_home/strings.json | 20 + .../melcloud_home/fixtures/context.json | 2 +- .../snapshots/test_diagnostics.ambr | 2 +- .../melcloud_home/snapshots/test_number.ambr | 489 ++++++++++++++++++ .../melcloud_home/snapshots/test_switch.ambr | 2 +- tests/components/melcloud_home/test_number.py | 310 +++++++++++ 9 files changed, 1263 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/melcloud_home/number.py create mode 100644 tests/components/melcloud_home/snapshots/test_number.ambr create mode 100644 tests/components/melcloud_home/test_number.py diff --git a/homeassistant/components/melcloud_home/__init__.py b/homeassistant/components/melcloud_home/__init__.py index def9dc20f815..762b97846120 100644 --- a/homeassistant/components/melcloud_home/__init__.py +++ b/homeassistant/components/melcloud_home/__init__.py @@ -11,6 +11,7 @@ from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/melcloud_home/icons.json b/homeassistant/components/melcloud_home/icons.json index 2c7d55ef5f27..dc98ac2263b0 100644 --- a/homeassistant/components/melcloud_home/icons.json +++ b/homeassistant/components/melcloud_home/icons.json @@ -29,6 +29,20 @@ } } }, + "number": { + "frost_protection_max_temp": { + "default": "mdi:thermometer" + }, + "frost_protection_min_temp": { + "default": "mdi:thermometer-low" + }, + "overheat_protection_max_temp": { + "default": "mdi:thermometer-high" + }, + "overheat_protection_min_temp": { + "default": "mdi:thermometer" + } + }, "sensor": { "room_temperature": { "default": "mdi:home-thermometer" diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py new file mode 100644 index 000000000000..65e2fbfd5868 --- /dev/null +++ b/homeassistant/components/melcloud_home/number.py @@ -0,0 +1,426 @@ +"""Number platform for MELCloud Home.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, override + +from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class ATANumberEntityDescription(NumberEntityDescription): + """Class to hold MELCloud Home ATA number description.""" + + available_fn: Callable[[ATAUnit], bool] + value_fn: Callable[[ATAUnit], float | None] + set_value_fn: Callable[[MELCloudHome, ATAUnit, float], Coroutine[Any, Any, None]] + validate_fn: Callable[[ATAUnit, float], str | None] | None = None + + +@dataclass(frozen=True, kw_only=True) +class ATWNumberEntityDescription(NumberEntityDescription): + """Class to hold MELCloud Home ATW number description.""" + + available_fn: Callable[[ATWUnit], bool] + value_fn: Callable[[ATWUnit], float | None] + set_value_fn: Callable[[MELCloudHome, ATWUnit, float], Coroutine[Any, Any, None]] + validate_fn: Callable[[ATWUnit, float], str | None] | None = None + + +ATA_NUMBERS: tuple[ATANumberEntityDescription, ...] = ( + ATANumberEntityDescription( + key="frost_protection_min_temp", + translation_key="frost_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.min if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=value, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.frost_protection and value >= unit.frost_protection.max + else None + ), + ), + ATANumberEntityDescription( + key="frost_protection_max_temp", + translation_key="frost_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.max if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=value, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.frost_protection and value <= unit.frost_protection.min + else None + ), + ), + ATANumberEntityDescription( + key="overheat_protection_min_temp", + translation_key="overheat_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=31.0, + native_max_value=40.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.min if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=value, + max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.overheat_protection and value >= unit.overheat_protection.max + else None + ), + ), + ATANumberEntityDescription( + key="overheat_protection_max_temp", + translation_key="overheat_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=31.0, + native_max_value=40.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.max if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, + max_temp=value, + ata_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.overheat_protection and value <= unit.overheat_protection.min + else None + ), + ), +) + +ATW_NUMBERS: tuple[ATWNumberEntityDescription, ...] = ( + ATWNumberEntityDescription( + key="frost_protection_min_temp", + translation_key="frost_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.min if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=value, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.frost_protection and value >= unit.frost_protection.max + else None + ), + ), + ATWNumberEntityDescription( + key="frost_protection_max_temp", + translation_key="frost_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.max if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled if unit.frost_protection else False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=value, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.frost_protection and value <= unit.frost_protection.min + else None + ), + ), + ATWNumberEntityDescription( + key="overheat_protection_min_temp", + translation_key="overheat_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=20.0, + native_max_value=60.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.min if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=value, + max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.overheat_protection and value >= unit.overheat_protection.max + else None + ), + ), + ATWNumberEntityDescription( + key="overheat_protection_max_temp", + translation_key="overheat_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=20.0, + native_max_value=60.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.max if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, + max_temp=value, + atw_unit_ids=[unit.id], + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.overheat_protection and value <= unit.overheat_protection.min + else None + ), + ), +) + + +async def _perform_action( + coordinator: MelCloudHomeCoordinator, + coroutine: Coroutine[Any, Any, None], +) -> None: + """Perform a MELCloud Home action with error handling and coordinator refresh.""" + try: + await coroutine + except MelCloudHomeAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except MelCloudHomeConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except MelCloudHomeTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + ) from err + else: + await coordinator.async_request_refresh() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MelCloudHomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MELCloud Home numbers.""" + coordinator = entry.runtime_data + + def _async_add_new_ata_units(units: list[ATAUnit]) -> None: + async_add_entities( + ATANumber(coordinator, entity_description, unit) + for entity_description in ATA_NUMBERS + for unit in units + ) + + def _async_add_new_atw_units(units: list[ATWUnit]) -> None: + async_add_entities( + ATWNumber(coordinator, entity_description, unit) + for entity_description in ATW_NUMBERS + for unit in units + ) + + coordinator.new_ata_callbacks.append(_async_add_new_ata_units) + coordinator.new_atw_callbacks.append(_async_add_new_atw_units) + + _async_add_new_ata_units(list(coordinator.ata_units.values())) + _async_add_new_atw_units(list(coordinator.atw_units.values())) + + +class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): + """Representation of a MELCloud Home ATA number.""" + + entity_description: ATANumberEntityDescription + + def __init__( + self, + coordinator: MelCloudHomeCoordinator, + entity_description: ATANumberEntityDescription, + unit: ATAUnit, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + self.entity_description = entity_description + self._attr_unique_id = f"{unit.id}_{entity_description.key}" + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self.entity_description.available_fn(self.unit) + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.unit) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the protection temperature threshold.""" + if self.entity_description.validate_fn and ( + error_key := self.entity_description.validate_fn(self.unit, value) + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) + await _perform_action( + self.coordinator, + self.entity_description.set_value_fn( + self.coordinator.client, self.unit, value + ), + ) + + +class ATWNumber(MelCloudHomeATWUnitEntity, NumberEntity): + """Representation of a MELCloud Home ATW number.""" + + entity_description: ATWNumberEntityDescription + + def __init__( + self, + coordinator: MelCloudHomeCoordinator, + entity_description: ATWNumberEntityDescription, + unit: ATWUnit, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + self.entity_description = entity_description + self._attr_unique_id = f"{unit.id}_{entity_description.key}" + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self.entity_description.available_fn(self.unit) + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.unit) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the protection temperature threshold.""" + if self.entity_description.validate_fn and ( + error_key := self.entity_description.validate_fn(self.unit, value) + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) + await _perform_action( + self.coordinator, + self.entity_description.set_value_fn( + self.coordinator.client, self.unit, value + ), + ) diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json index e424162368d1..330bfd52224a 100644 --- a/homeassistant/components/melcloud_home/strings.json +++ b/homeassistant/components/melcloud_home/strings.json @@ -108,6 +108,20 @@ } } }, + "number": { + "frost_protection_max_temp": { + "name": "Frost protection maximum temperature" + }, + "frost_protection_min_temp": { + "name": "Frost protection minimum temperature" + }, + "overheat_protection_max_temp": { + "name": "Overheat protection maximum temperature" + }, + "overheat_protection_min_temp": { + "name": "Overheat protection minimum temperature" + } + }, "sensor": { "energy_consumed": { "name": "Energy consumed (monthly)" @@ -141,6 +155,12 @@ "invalid_auth": { "message": "An error occurred while trying to authenticate" }, + "temperature_max_below_min": { + "message": "The maximum temperature must be higher than the minimum temperature." + }, + "temperature_min_exceeds_max": { + "message": "The minimum temperature must be lower than the maximum temperature." + }, "timeout_connect": { "message": "Timeout while communicating with MELCloud Home API" } diff --git a/tests/components/melcloud_home/fixtures/context.json b/tests/components/melcloud_home/fixtures/context.json index 67ec381cdc69..db95462950fb 100644 --- a/tests/components/melcloud_home/fixtures/context.json +++ b/tests/components/melcloud_home/fixtures/context.json @@ -195,7 +195,7 @@ "rssi": -52, "frostProtection": { "active": false, - "enabled": false, + "enabled": true, "min": 5, "max": 8 }, diff --git a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr index 950bf9dc298a..e6e1255a61ff 100644 --- a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr @@ -153,7 +153,7 @@ 'forced_hot_water_mode': False, 'frost_protection': dict({ 'active': False, - 'enabled': False, + 'enabled': True, 'max': 8.0, 'min': 5.0, }), diff --git a/tests/components/melcloud_home/snapshots/test_number.ambr b/tests/components/melcloud_home/snapshots/test_number.ambr new file mode 100644 index 000000000000..8a757dd27310 --- /dev/null +++ b/tests/components/melcloud_home/snapshots/test_number.ambr @@ -0,0 +1,489 @@ +# serializer version: 1 +# name: test_all_entities[number.heat_pump_frost_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_frost_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_max_temp', + 'unique_id': 'atw-unit-uuid-1_frost_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Frost protection maximum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_frost_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_frost_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_min_temp', + 'unique_id': 'atw-unit-uuid-1_frost_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Frost protection minimum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_frost_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 20.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_overheat_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_max_temp', + 'unique_id': 'atw-unit-uuid-1_overheat_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Overheat protection maximum temperature', + : 60.0, + : 20.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_overheat_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 20.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_overheat_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_min_temp', + 'unique_id': 'atw-unit-uuid-1_overheat_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Overheat protection minimum temperature', + : 60.0, + : 20.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_overheat_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_frost_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_max_temp', + 'unique_id': 'ata-unit-uuid-1_frost_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Frost protection maximum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_frost_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_frost_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_min_temp', + 'unique_id': 'ata-unit-uuid-1_frost_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Frost protection minimum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_frost_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 31.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_overheat_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_max_temp', + 'unique_id': 'ata-unit-uuid-1_overheat_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Overheat protection maximum temperature', + : 40.0, + : 31.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_overheat_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 31.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_overheat_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_min_temp', + 'unique_id': 'ata-unit-uuid-1_overheat_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Overheat protection minimum temperature', + : 40.0, + : 31.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_overheat_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.0', + }) +# --- diff --git a/tests/components/melcloud_home/snapshots/test_switch.ambr b/tests/components/melcloud_home/snapshots/test_switch.ambr index 4d3f0f73f2f2..a75a0b091d8c 100644 --- a/tests/components/melcloud_home/snapshots/test_switch.ambr +++ b/tests/components/melcloud_home/snapshots/test_switch.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_all_entities[switch.heat_pump_overheat_protection-entry] diff --git a/tests/components/melcloud_home/test_number.py b/tests/components/melcloud_home/test_number.py new file mode 100644 index 000000000000..d9ce0d16b1b4 --- /dev/null +++ b/tests/components/melcloud_home/test_number.py @@ -0,0 +1,310 @@ +"""Tests for the MELCloud Home number platform.""" + +from unittest.mock import AsyncMock, patch + +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all number entities.""" + with patch( + "homeassistant.components.melcloud_home.PLATFORMS", + [Platform.NUMBER], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("entity_id", "method", "expected_kwargs", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 11.0, + "max_temp": 12.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 11.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 10.0, + "max_temp": 13.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 13.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 36.0, + "max_temp": 37.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 36.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 35.0, + "max_temp": 38.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 38.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 6.0, + "max_temp": 8.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 6.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 5.0, + "max_temp": 9.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 9.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 41.0, + "max_temp": 42.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 41.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 40.0, + "max_temp": 43.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 43.0, + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, + expected_kwargs: dict, + value: float, +) -> None: + """Test number set_value calls the correct API method.""" + await setup_integration(hass, mock_config_entry) + + method_mock = getattr(mock_melcloud_client, method) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + method_mock.assert_called_once_with(**expected_kwargs) + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + 12.0, + ), + ( + "number.living_room_ac_frost_protection_minimum_temperature", + 13.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + 10.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + 9.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + 37.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + 35.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + 8.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + 5.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + 42.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + 40.0, + ), + ], +) +async def test_set_value_validation_error( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + value: float, +) -> None: + """Test that setting min >= max or max <= min raises HomeAssistantError.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + mock_melcloud_client.set_frost_protection.assert_not_called() + mock_melcloud_client.set_overheat_protection.assert_not_called() + + +@pytest.mark.parametrize( + ("entity_id", "method", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + "set_frost_protection", + 10.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + "set_frost_protection", + 12.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + "set_overheat_protection", + 35.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + "set_overheat_protection", + 37.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + "set_frost_protection", + 5.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + "set_frost_protection", + 8.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + "set_overheat_protection", + 40.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + "set_overheat_protection", + 42.0, + ), + ], +) +@pytest.mark.parametrize( + ("raise_exception", "expected_exception"), + [ + (MelCloudHomeAuthenticationError, HomeAssistantError), + (MelCloudHomeConnectionError, HomeAssistantError), + (MelCloudHomeTimeoutError, HomeAssistantError), + ], +) +async def test_set_value_exceptions( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, + value: float, + raise_exception: type[Exception], + expected_exception: type[Exception], +) -> None: + """Test number actions raise HomeAssistantError on client errors.""" + await setup_integration(hass, mock_config_entry) + + method_mock = getattr(mock_melcloud_client, method) + method_mock.side_effect = raise_exception + + with pytest.raises(expected_exception): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) From cf5497039907f5d645089745fed0ea101e739d5e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 13:44:14 +0200 Subject: [PATCH 351/707] Fix garbled Z-Wave discovery card titles (#176055) Co-authored-by: Claude --- .../components/zwave_js/config_flow.py | 11 +++++---- .../components/zwave_js/strings.json | 2 +- tests/components/zwave_js/test_config_flow.py | 24 ++++++++++++++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 32d60b6269ec..db6fde0f7871 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -456,13 +456,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() self.ws_address = f"ws://{discovery_info.host}:{discovery_info.port}" home_id_display = format_home_id_for_display(int(home_id)) - # Show home ID and network location in discovery notification self.context.update( { "title_placeholders": { - "host": discovery_info.host, - "port": str(discovery_info.port), - "home_id": home_id_display, + CONF_NAME: ( + f"Network {home_id_display} at " + f"{discovery_info.host}:{discovery_info.port}" + ) } } ) @@ -1574,8 +1574,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) self.socket_path = discovery_info.socket_path + home_id_display = format_home_id_for_display(discovery_info.zwave_home_id) self.context["title_placeholders"] = { - CONF_NAME: f"{discovery_info.name} via ESPHome" + CONF_NAME: f"Network {home_id_display} via {discovery_info.name} (ESPHome)" } self._adapter_discovered = True diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 48ef507a46a9..e7f13d332e74 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -30,7 +30,7 @@ "invalid_ws_url": "Invalid websocket URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, - "flow_title": "Network {home_id} at {host}:{port}", + "flow_title": "{name}", "progress": { "backup_nvm": "Please wait while the network backup completes", "install_addon": "Installation can take several minutes", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 85b0e2c54192..8088fb13ec36 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1187,6 +1187,23 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert "keep_old_devices" in entry.data +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_esphome_discovery_title_placeholders(hass: HomeAssistant) -> None: + """Test ESPHome discovery sets the name placeholder for the flow_title.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert ( + flows[0]["context"]["title_placeholders"]["name"] + == "Network 0x000004d2 via mock-name (ESPHome)" + ) + + @pytest.mark.parametrize( "service_info", [ESPHOME_DISCOVERY_INFO, ESPHOME_DISCOVERY_INFO_CLEAN] ) @@ -4087,9 +4104,10 @@ async def test_zeroconf(hass: HomeAssistant) -> None: flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 flow = flows[0] - assert flow["context"]["title_placeholders"]["host"] == "127.0.0.1" - assert flow["context"]["title_placeholders"]["port"] == "3000" - assert flow["context"]["title_placeholders"]["home_id"] == "0x000004d2" # 1234 + assert ( + flow["context"]["title_placeholders"]["name"] + == "Network 0x000004d2 at 127.0.0.1:3000" + ) with ( patch( From baf50c7c49780d768b9d4c41f2a2c5fe6bb80579 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Thu, 9 Jul 2026 13:44:44 +0200 Subject: [PATCH 352/707] Fix inverted My position number for Overkiz covers (#174414) --- homeassistant/components/overkiz/number.py | 32 ++++++++---- .../overkiz/snapshots/test_number.ambr | 50 +++++++++---------- tests/components/overkiz/test_number.py | 23 +++++++++ 3 files changed, 71 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 72198368f18b..b20637b95d00 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import cast, override from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES from .coordinator import OverkizDataUpdateCoordinator +from .cover import SUPPORTED_DEVICES as SUPPORTED_COVER_DEVICES from .entity import OverkizDescriptiveEntity BOOST_MODE_DURATION_DELAY = 1 @@ -206,15 +207,28 @@ async def async_setup_entry( ): continue - entities.extend( - OverkizNumber( - device.device_url, - data.coordinator, - description, + for state in device.definition.states: + if not (description := SUPPORTED_STATES.get(state)): + continue + + # Mirror the cover's position inversion. + if description.key == OverkizState.CORE_MEMORIZED_1_POSITION and ( + cover_description := ( + SUPPORTED_COVER_DEVICES.get(device.widget) + or SUPPORTED_COVER_DEVICES.get(device.ui_class) + ) + ): + description = replace( + description, inverted=cover_description.invert_position + ) + + entities.append( + OverkizNumber( + device.device_url, + data.coordinator, + description, + ) ) - for state in device.definition.states - if (description := SUPPORTED_STATES.get(state)) - ) async_add_entities(entities) diff --git a/tests/components/overkiz/snapshots/test_number.ambr b/tests/components/overkiz/snapshots/test_number.ambr index 6fcac0d69d77..80410a24e3d3 100644 --- a/tests/components/overkiz/snapshots/test_number.ambr +++ b/tests/components/overkiz/snapshots/test_number.ambr @@ -484,7 +484,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_inlet_my_position-entry] @@ -786,7 +786,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_office_shutter_my_position-entry] @@ -846,7 +846,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_study_radiator_comfort_room_temperature-entry] @@ -1154,7 +1154,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.garage_dining_room_shutter_my_position-entry] @@ -1214,7 +1214,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '69', + 'state': '31', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.garage_guest_room_shutter_my_position-entry] @@ -1274,7 +1274,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '84', + 'state': '16', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.guest_bedroom_kids_room_shutter_my_position-entry] @@ -1334,7 +1334,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_kitchen_shutter_my_position-entry] @@ -1394,7 +1394,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '87', + 'state': '13', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_office_shutter_my_position-entry] @@ -1454,7 +1454,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '72', + 'state': '28', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_patio_shutter_my_position-entry] @@ -1514,7 +1514,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '81', + 'state': '19', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.main_bedroom_bedroom_blinds_my_position-entry] @@ -1574,7 +1574,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.office_garden_house_shutter_my_position-entry] @@ -1634,7 +1634,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.reading_nook_living_room_shutter_my_position-entry] @@ -1694,7 +1694,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.bathroom_blinds_my_position-entry] @@ -1754,7 +1754,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.bedroom_blinds_my_position-entry] @@ -1814,7 +1814,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.dining_room_blinds_my_position-entry] @@ -1874,7 +1874,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.garage_blinds_my_position-entry] @@ -1934,7 +1934,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.guest_room_blinds_my_position-entry] @@ -1994,7 +1994,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.hallway_blinds_my_position-entry] @@ -2054,7 +2054,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.kitchen_blinds_my_position-entry] @@ -2114,7 +2114,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.living_room_blinds_my_position-entry] @@ -2174,7 +2174,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.master_bedroom_blinds_my_position-entry] @@ -2234,7 +2234,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.nursery_blinds_my_position-entry] @@ -2294,7 +2294,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.office_blinds_my_position-entry] @@ -2354,7 +2354,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.study_blinds_my_position-entry] @@ -2414,6 +2414,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- diff --git a/tests/components/overkiz/test_number.py b/tests/components/overkiz/test_number.py index 55ee178ee763..8d570920bc10 100644 --- a/tests/components/overkiz/test_number.py +++ b/tests/components/overkiz/test_number.py @@ -110,6 +110,29 @@ async def test_number_set_value( ) +async def test_number_inverted_memorized_position_set( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """Test that setting a cover's "My position" inverts before sending.""" + await setup_overkiz_integration(fixture=OFFICE_BLINDS_MEMORIZED_POSITION.fixture) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: OFFICE_BLINDS_MEMORIZED_POSITION.entity_id, ATTR_VALUE: 15}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=OFFICE_BLINDS_MEMORIZED_POSITION.device_url, + command_name="setMemorized1Position", + parameters=[85], + ) + + async def test_number_dynamic_min_max( hass: HomeAssistant, setup_overkiz_integration: SetupOverkizIntegration, From d289d44dde003da33f9430afcf693d725f87095b Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Thu, 9 Jul 2026 07:45:07 -0400 Subject: [PATCH 353/707] Add radar loop duration and frame rate options to Environment Canada (#175325) Co-authored-by: Claude Sonnet 5 Co-authored-by: Joost Lekkerkerker --- .../components/environment_canada/__init__.py | 6 +++++ .../environment_canada/config_flow.py | 20 +++++++++++++++++ .../components/environment_canada/const.py | 5 +++++ .../environment_canada/strings.json | 4 ++++ .../environment_canada/test_config_flow.py | 22 +++++++++++++++++++ 5 files changed, 57 insertions(+) diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 2572c735fa74..a43e350a45fc 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -12,12 +12,16 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, CONF_RADAR_TIMESTAMP, CONF_STATION, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -75,6 +79,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP), layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)), radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)), + loop_minutes=int(options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION)), + fps=int(options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS)), ) radar_coordinator = ECDataUpdateCoordinator( hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index c5aadd3bd3e9..3a56dd576f6b 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -30,6 +30,8 @@ from homeassistant.helpers.selector import ( ) from .const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, @@ -37,6 +39,8 @@ from .const import ( CONF_RADAR_TIMESTAMP, CONF_STATION, CONF_TITLE, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -210,6 +214,22 @@ class OptionsFlowHandler(OptionsFlowWithReload): min=10, max=2000, step=10, unit_of_measurement="km" ) ), + vol.Required( + CONF_RADAR_DURATION, + default=options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION), + ): NumberSelector( + NumberSelectorConfig( + min=0, max=180, step=5, unit_of_measurement="min" + ) + ), + vol.Required( + CONF_RADAR_FPS, + default=options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS), + ): NumberSelector( + NumberSelectorConfig( + min=1, max=30, step=1, unit_of_measurement="fps" + ) + ), } ) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index 39a9f3a949a7..52cf4b6564e4 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -12,6 +12,8 @@ CONF_RADAR_LEGEND = "radar_legend" CONF_RADAR_TIMESTAMP = "radar_timestamp" CONF_RADAR_OPACITY = "radar_opacity" CONF_RADAR_RADIUS = "radar_radius" +CONF_RADAR_DURATION = "radar_duration" +CONF_RADAR_FPS = "radar_fps" RADAR_LAYERS = ["rain", "snow", "precip_type"] @@ -22,3 +24,6 @@ DEFAULT_RADAR_LEGEND = False DEFAULT_RADAR_TIMESTAMP = True DEFAULT_RADAR_OPACITY = 65 DEFAULT_RADAR_RADIUS = 200 +# 0 means use the full range of images Environment Canada reports as available. +DEFAULT_RADAR_DURATION = 0 +DEFAULT_RADAR_FPS = 5 diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 93acc0ecc558..18491e942b25 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -121,6 +121,8 @@ "step": { "init": { "data": { + "radar_duration": "Loop duration", + "radar_fps": "Loop frame rate", "radar_layer": "Radar type", "radar_legend": "Show legend", "radar_opacity": "Radar opacity", @@ -128,6 +130,8 @@ "radar_timestamp": "Show timestamp" }, "data_description": { + "radar_duration": "How far back the radar animation goes, in minutes (0 for the full available history)", + "radar_fps": "Frame rate of the radar animation", "radar_opacity": "Opacity of the radar layer overlay (0-100)", "radar_radius": "Radius of the radar map in kilometres" }, diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 7a5d88b64bf3..cd23ae7194bb 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -9,12 +9,16 @@ import pytest from homeassistant import config_entries from homeassistant.components.environment_canada.const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, CONF_RADAR_TIMESTAMP, CONF_STATION, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -246,6 +250,8 @@ async def test_options_flow_form(hass: HomeAssistant, ec_data: dict[str, Any]) - CONF_RADAR_TIMESTAMP, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, + CONF_RADAR_DURATION, + CONF_RADAR_FPS, } @@ -261,6 +267,8 @@ async def test_options_flow_save(hass: HomeAssistant, ec_data: dict[str, Any]) - CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 30, CONF_RADAR_RADIUS: 100, + CONF_RADAR_DURATION: 30, + CONF_RADAR_FPS: 10, } with patch( "homeassistant.components.environment_canada.async_setup_entry", @@ -286,6 +294,8 @@ async def test_options_flow_prefills_saved_options( CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 50, CONF_RADAR_RADIUS: 300, + CONF_RADAR_DURATION: 60, + CONF_RADAR_FPS: 15, } config_entry = await init_integration(hass, ec_data, options=saved_options) @@ -297,6 +307,8 @@ async def test_options_flow_prefills_saved_options( assert defaults[CONF_RADAR_TIMESTAMP] is False assert defaults[CONF_RADAR_OPACITY] == 50 assert defaults[CONF_RADAR_RADIUS] == 300 + assert defaults[CONF_RADAR_DURATION] == 60 + assert defaults[CONF_RADAR_FPS] == 15 @pytest.mark.parametrize( @@ -310,6 +322,8 @@ async def test_options_flow_prefills_saved_options( "timestamp": DEFAULT_RADAR_TIMESTAMP, "layer_opacity": DEFAULT_RADAR_OPACITY, "radius": DEFAULT_RADAR_RADIUS, + "loop_minutes": DEFAULT_RADAR_DURATION, + "fps": DEFAULT_RADAR_FPS, }, id="defaults", ), @@ -320,6 +334,8 @@ async def test_options_flow_prefills_saved_options( CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 40.0, CONF_RADAR_RADIUS: 150.0, + CONF_RADAR_DURATION: 30.0, + CONF_RADAR_FPS: 10.0, }, { "layer": "snow", @@ -327,6 +343,8 @@ async def test_options_flow_prefills_saved_options( "timestamp": False, "layer_opacity": 40, "radius": 150, + "loop_minutes": 30, + "fps": 10, }, id="custom", ), @@ -351,3 +369,7 @@ async def test_ecmap_built_from_options( assert isinstance(kwargs["layer_opacity"], int) assert kwargs["radius"] == expected["radius"] assert isinstance(kwargs["radius"], int) + assert kwargs["loop_minutes"] == expected["loop_minutes"] + assert isinstance(kwargs["loop_minutes"], int) + assert kwargs["fps"] == expected["fps"] + assert isinstance(kwargs["fps"], int) From 4e76a288e8ec9ff50612eac1e4faa1b177a88816 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:45:29 +0200 Subject: [PATCH 354/707] Use Attribute enum in media_player (#175375) --- .../components/media_player/__init__.py | 3 +- .../components/media_player/condition.py | 20 +++--- .../media_player/reproduce_state.py | 72 ++++++++++++++----- .../media_player/significant_change.py | 21 +++--- .../components/media_player/trigger.py | 21 +++--- 5 files changed, 88 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f505c3505017..881aa71d11a1 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -49,6 +49,7 @@ from homeassistant.const import ( # noqa: F401 STATE_OFF, STATE_PLAYING, STATE_STANDBY, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv @@ -543,7 +544,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _entity_component_unrecorded_attributes = frozenset( { MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL, - ATTR_ENTITY_PICTURE, + EntityStateAttribute.ENTITY_PICTURE, MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST, MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT, MediaPlayerEntityStateAttribute.MEDIA_POSITION, diff --git a/homeassistant/components/media_player/condition.py b/homeassistant/components/media_player/condition.py index d95fedf92cb2..639466a0dde9 100644 --- a/homeassistant/components/media_player/condition.py +++ b/homeassistant/components/media_player/condition.py @@ -12,11 +12,10 @@ from homeassistant.helpers.condition import ( make_entity_state_condition, ) -from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED -from .const import DOMAIN, MediaPlayerState +from .const import DOMAIN, MediaPlayerEntityStateAttribute, MediaPlayerState VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), + DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL), } @@ -38,8 +37,10 @@ class _MediaPlayerMutedConditionBase(EntityConditionBase): def _has_volume_attributes(self, state: State) -> bool: """Check if the state has volume muted or volume level attributes.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is not None + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) @override @@ -50,8 +51,10 @@ class _MediaPlayerMutedConditionBase(EntityConditionBase): def _is_muted(self, state: State) -> bool: """Check if the media player is muted.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is True + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + == 0 ) @override @@ -96,7 +99,8 @@ class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase): """Skip media players that do not expose a volume_level attribute.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index b4c2c4f821fb..a498966c2c9b 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -5,7 +5,6 @@ from collections.abc import Iterable from typing import Any from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, @@ -19,6 +18,7 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, + EntityStateAttribute, ) from homeassistant.core import Context, HomeAssistant, State @@ -34,8 +34,19 @@ from .const import ( SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, ) +# Maps a state attribute to the service call argument used to restore it. +_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[MediaPlayerEntityStateAttribute, str] = { + MediaPlayerEntityStateAttribute.INPUT_SOURCE: ATTR_INPUT_SOURCE, + MediaPlayerEntityStateAttribute.SOUND_MODE: ATTR_SOUND_MODE, + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: ATTR_MEDIA_VOLUME_LEVEL, + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED: ATTR_MEDIA_VOLUME_MUTED, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE: ATTR_MEDIA_CONTENT_TYPE, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID: ATTR_MEDIA_CONTENT_ID, +} + async def _async_reproduce_states( hass: HomeAssistant, @@ -46,14 +57,22 @@ async def _async_reproduce_states( ) -> None: """Reproduce component states.""" cur_state = hass.states.get(state.entity_id) - features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 + features = ( + cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + if cur_state + else 0 + ) - async def call_service(service: str, keys: Iterable[str]) -> None: - """Call service with set of attributes given.""" + async def call_service( + service: str, attributes: Iterable[MediaPlayerEntityStateAttribute] + ) -> None: + """Call service with the given state attributes.""" data = {"entity_id": state.entity_id} - for key in keys: - if key in state.attributes: - data[key] = state.attributes[key] + for attribute in attributes: + if attribute in state.attributes: + data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = state.attributes[ + attribute + ] await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context @@ -79,42 +98,57 @@ async def _async_reproduce_states( await call_service(SERVICE_TURN_ON, []) cur_state = hass.states.get(state.entity_id) - features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 + features = ( + cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + if cur_state + else 0 + ) # First set source & sound mode to match the saved supported features if ( - ATTR_INPUT_SOURCE in state.attributes + MediaPlayerEntityStateAttribute.INPUT_SOURCE in state.attributes and features & MediaPlayerEntityFeature.SELECT_SOURCE ): - await call_service(SERVICE_SELECT_SOURCE, [ATTR_INPUT_SOURCE]) + await call_service( + SERVICE_SELECT_SOURCE, [MediaPlayerEntityStateAttribute.INPUT_SOURCE] + ) if ( - ATTR_SOUND_MODE in state.attributes + MediaPlayerEntityStateAttribute.SOUND_MODE in state.attributes and features & MediaPlayerEntityFeature.SELECT_SOUND_MODE ): - await call_service(SERVICE_SELECT_SOUND_MODE, [ATTR_SOUND_MODE]) + await call_service( + SERVICE_SELECT_SOUND_MODE, [MediaPlayerEntityStateAttribute.SOUND_MODE] + ) if ( - ATTR_MEDIA_VOLUME_LEVEL in state.attributes + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL in state.attributes and features & MediaPlayerEntityFeature.VOLUME_SET ): - await call_service(SERVICE_VOLUME_SET, [ATTR_MEDIA_VOLUME_LEVEL]) + await call_service( + SERVICE_VOLUME_SET, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL] + ) if ( - ATTR_MEDIA_VOLUME_MUTED in state.attributes + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED in state.attributes and features & MediaPlayerEntityFeature.VOLUME_MUTE ): - await call_service(SERVICE_VOLUME_MUTE, [ATTR_MEDIA_VOLUME_MUTED]) + await call_service( + SERVICE_VOLUME_MUTE, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED] + ) already_playing = False - if (ATTR_MEDIA_CONTENT_TYPE in state.attributes) and ( - ATTR_MEDIA_CONTENT_ID in state.attributes + if (MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE in state.attributes) and ( + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID in state.attributes ): if features & MediaPlayerEntityFeature.PLAY_MEDIA: await call_service( SERVICE_PLAY_MEDIA, - [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID], + [ + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID, + ], ) already_playing = True diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index f9d81b9135dc..c3320f3354ab 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -8,21 +8,16 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ( - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_MEDIA_VOLUME_LEVEL, - PROP_TO_ATTR, -) +from . import PROP_TO_ATTR +from .const import MediaPlayerEntityStateAttribute -INSIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, +INSIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = { + MediaPlayerEntityStateAttribute.MEDIA_POSITION, + MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT, } -SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_ENTITY_PICTURE_LOCAL, +SIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = { + MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL, *PROP_TO_ATTR.values(), } - INSIGNIFICANT_ATTRIBUTES @@ -49,7 +44,7 @@ def async_check_significant_change( changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name != ATTR_MEDIA_VOLUME_LEVEL: + if attr_name != MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: return True old_attr_value = old_attrs.get(attr_name) diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 36c9e1c936ff..0629797de740 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -14,11 +14,11 @@ from homeassistant.helpers.trigger import ( make_entity_transition_trigger, ) -from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState -from .const import DOMAIN +from . import MediaPlayerState +from .const import DOMAIN, MediaPlayerEntityStateAttribute VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), + DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL), } @@ -31,8 +31,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): def _has_volume_attributes(self, state: State) -> bool: """Check if the state has volume muted or volume level attributes.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is not None + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) @override @@ -48,8 +50,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): def is_muted(self, state: State) -> bool: """Check if the media player is muted.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is True + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + == 0 ) @override @@ -109,7 +113,8 @@ class VolumeTriggerMixin(EntityNumericalStateTriggerBase): """ return ( super()._should_include(state) - and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) From a86c7d3b0447bb50f0239887781a1dffb5b2e945 Mon Sep 17 00:00:00 2001 From: Mike N8RAW <7760516+mkmer@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:13:20 -0400 Subject: [PATCH 355/707] Bump aiosomecomfort to 0.0.38 (#176081) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 451202b73a6b..6804e6ef202f 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.37"] + "requirements": ["AIOSomecomfort==0.0.38"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe5d9c639e3e..373fa38c1502 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.37 +AIOSomecomfort==0.0.38 # homeassistant.components.adax Adax-local==0.3.0 From 29b4acda8d9c20ac375c63df73295aa632340431 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:17:40 +0200 Subject: [PATCH 356/707] Key UniFi Protect public event subscriptions by device id (#175356) --- homeassistant/components/unifiprotect/data.py | 20 +++++++++---------- .../components/unifiprotect/event.py | 4 ++-- tests/components/unifiprotect/test_event.py | 12 ++++++----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 577b47fd1867..cdc7f7ed891e 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -245,18 +245,16 @@ class ProtectData: Only the start of an event is dispatched, routed to the subscribers that registered for this device and event type; an entity that cares about a - sub-type (e.g. a smart-detect object type) filters further itself. The - device is resolved by ``device_id`` (the stable cross-API join key), not - the public ``device_mac``, so the key comes from the same store the - entities derive ``self.device.mac`` from and matches without assuming - both mac strings are byte-identical. + sub-type (e.g. a smart-detect object type) filters further itself. + Subscriptions are keyed by ``device_id`` (the stable cross-API join key, + shared by the private and public bootstraps), so the event routes + directly without a bootstrap lookup. """ if change is not EventChange.STARTED: return - device = self.api.bootstrap.get_device_from_id(event.device_id) - if device is None or not ( + if not ( subscriptions := self._public_event_subscriptions.get( - (device.mac, event.type) + (event.device_id, event.type) ) ): return @@ -492,12 +490,12 @@ class ProtectData: @callback def async_subscribe_public_event( self, - mac: str, + device_id: str, event_type: EventType, update_callback: Callable[[ProtectEvent], None], ) -> CALLBACK_TYPE: - """Add a callback subscriber for public events of a type by device mac.""" - key = (mac, event_type) + """Add a callback subscriber for public events of a type by device id.""" + key = (device_id, event_type) self._public_event_subscriptions[key].add(update_callback) return partial(self._async_unsubscribe_public_event, key, update_callback) diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 29d824de65fc..4875c4b9697f 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -86,7 +86,7 @@ class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventE await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_public_event( - self.device.mac, EventType.RING, self._async_ring_event + self.device.id, EventType.RING, self._async_ring_event ) ) @@ -382,7 +382,7 @@ class ProtectDeviceSmartDetectEventEntity( await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_public_event( - self.device.mac, EventType.SMART_DETECT, self._async_smart_detect_event + self.device.id, EventType.SMART_DETECT, self._async_smart_detect_event ) ) diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index a21771f847be..d22fcbc6a652 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -244,11 +244,13 @@ async def test_package_detected( await hass.async_block_till_done() assert len(events) == 1 - # The camera is resolved by device_id, not by the public device_mac, so a - # missing device_mac must still dispatch. + # Subscriptions are keyed by device_id alone: an event still dispatches when + # it carries no device_mac and the device is absent from the private + # bootstrap, proving the public event path does not depend on it. + ufp.api.bootstrap.id_lookup.pop(doorbell.id, None) ufp.events_msg( ProtectEvent( - id="test_package_event_fallback", + id="test_package_event_no_private_device", type=EventType.SMART_DETECT, channel=ProtectEventChannel.DETECTION, device_id=doorbell.id, @@ -262,10 +264,10 @@ async def test_package_detected( await hass.async_block_till_done() assert len(events) == 2 assert events[1].data["new_state"].attributes[ATTR_EVENT_ID] == ( - "test_package_event_fallback" + "test_package_event_no_private_device" ) - # An event for a device absent from the private bootstrap is dropped. + # An event for a device without a matching subscription is dropped. ufp.events_msg( ProtectEvent( id="test_package_event_unknown", From 95a34c1073b041034303f5273d0bc40324f0d1a5 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 9 Jul 2026 06:20:40 -0600 Subject: [PATCH 357/707] Add latest Orbi Wi-Fi 7 routers to V2 models (#175062) --- homeassistant/components/netgear/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 6221de06693e..f05a571755af 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -50,6 +50,7 @@ PORT_5555 = 5555 # update method V2 models MODELS_V2 = [ "Orbi", + "RBE", "RBK", "RBR", "RBS", From a6dfbae80425e37b716296869ffb5c5ea0443048 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:21:48 +0200 Subject: [PATCH 358/707] Migrate UniFi Protect HDR mode select to the public API (#174965) --- .../components/unifiprotect/select.py | 20 ++++- tests/components/unifiprotect/test_select.py | 76 ++++++++++++++++++- tests/components/unifiprotect/utils.py | 61 +++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 6d1491e41fb2..7789a3face2f 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import Enum import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.api import ProtectApiClient from uiprotect.data import ( @@ -25,7 +25,11 @@ from uiprotect.data import ( Sensor, Viewer, ) -from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicCamera, + PublicDeviceModel, + SensorFeatureCapability, +) from uiprotect.exceptions import GlobalAlarmManagerError from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -217,6 +221,16 @@ _HDR_MODE_MAP = { "always": PublicHdrMode.ON, "off": PublicHdrMode.OFF, } +_HDR_MODE_MAP_INVERSE = {v: k for k, v in _HDR_MODE_MAP.items()} + + +def _get_hdr_mode_public(obj: PublicDeviceModel) -> str | None: + """Return the HDR option id from the public camera's ``hdr_type``. + + ``hdr_type`` is non-optional on the public model; ``.get`` still yields + ``None`` for any value missing from the map. + """ + return _HDR_MODE_MAP_INVERSE.get(cast(PublicCamera, obj).hdr_type) async def _set_hdr_mode(obj: Camera, mode: str) -> None: @@ -282,7 +296,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", ufp_options=HDR_MODES, - ufp_value="hdr_mode_display", + ufp_public_value_fn=_get_hdr_mode_public, ufp_set_method_fn=_set_hdr_mode, ufp_perm=PermRequired.WRITE, ), diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index c3a67f49bb96..2173693e3ec2 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -8,6 +8,7 @@ from uiprotect.data import ( NVR, ArmProfile, Camera, + DeviceState, DoorbellMessageType, IRLEDMode, LCDMessage, @@ -35,7 +36,13 @@ from homeassistant.components.unifiprotect.select import ( PTZ_PATROL_STOP, VIEWER_SELECTS, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_OPTION, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -47,7 +54,10 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_camera, + public_device_ws_message, remove_entities, + setup_public_camera, ) @@ -159,6 +169,7 @@ async def test_select_setup_camera_all( ) -> None: """Test select entity setup for camera devices (all features).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) @@ -185,6 +196,67 @@ async def test_select_setup_camera_all( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION +async def test_select_camera_hdr_mode_public_update( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """Test the HDR mode select reads updates from the public devices WS.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + public = make_public_camera(doorbell, hdr_type=PublicHdrMode.AUTO) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "auto" + + +async def test_select_camera_hdr_mode_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """The migrated HDR mode select is unavailable without a public object.""" + + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_select_camera_hdr_mode_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """HDR mode availability follows the public object's connection state.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_camera(doorbell, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_select_setup_camera_none( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -193,6 +265,7 @@ async def test_select_setup_camera_none( ) -> None: """Test select entity setup for camera devices (no features).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -559,6 +632,7 @@ async def test_select_set_option_camera_hdr_mode( ) -> None: """Test HDR mode select calls public API with mapped value.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 70a713d40076..f3de49d75d2e 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -23,6 +23,8 @@ from uiprotect.data import ( ) from uiprotect.data.bootstrap import ProtectDeviceRef from uiprotect.data.public_devices import ( + PublicCamera, + PublicHdrMode, PublicLight, PublicLightDeviceSettings, PublicSensor, @@ -337,6 +339,38 @@ def make_public_light( return public +_HDR_DISPLAY_TO_PUBLIC = { + "auto": PublicHdrMode.AUTO, + "always": PublicHdrMode.ON, + "off": PublicHdrMode.OFF, +} + + +def make_public_camera( + camera: Camera, + *, + state: DeviceState | None = None, + hdr_type: PublicHdrMode | None = None, +) -> Mock: + """Build a public-API camera mirroring a private camera's migrated fields. + + ``hdr_type`` defaults to the public mode derived from the private + ``hdr_mode_display`` so the migrated HDR select reads the same value the + private object would produce; pass it to diverge from that. + """ + public = Mock(spec=PublicCamera) + public.id = camera.id + public.mac = camera.mac + public.model = ModelType.CAMERA + public.state = DeviceState[camera.state.name] if state is None else state + public.hdr_type = ( + _HDR_DISPLAY_TO_PUBLIC[camera.hdr_mode_display] + if hdr_type is None + else hdr_type + ) + return public + + def setup_public_sensor( ufp: MockUFPFixture, capabilities: set[SensorFeatureCapability] | None = None, @@ -398,6 +432,33 @@ def setup_public_light(ufp: MockUFPFixture) -> None: ufp.api.public_bootstrap = pb +def setup_public_camera(ufp: MockUFPFixture) -> None: + """Expose private cameras over the public API via a real ``PublicBootstrap``. + + Mirrors ``setup_public_sensor`` for ``ModelType.CAMERA`` so the migrated HDR + select reads from the public object. + """ + public_bootstrap = PublicBootstrap() + pb = Mock(spec=PublicBootstrap) + pb.cameras = public_bootstrap.cameras + pb.relays = {} + pb.sirens = {} + pb.arm_mode = None + pb.arm_profiles = {} + + def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: + if ( + model is ModelType.CAMERA + and (private := ufp.api.bootstrap.cameras.get(obj_id)) is not None + ): + public_bootstrap.cameras[obj_id] = make_public_camera(private) + return public_bootstrap.get(model, obj_id) + + pb.get = _get + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + def public_device_ws_message(public_obj: Mock) -> Mock: """Build a public devices WS message carrying a public object.""" msg = Mock() From c7e8e12feca1eb0655e3e5a11ddae2b557fc8f28 Mon Sep 17 00:00:00 2001 From: Stef Coene Date: Thu, 9 Jul 2026 14:23:52 +0200 Subject: [PATCH 359/707] Add velbus-handler to velbus integration loggers (#174904) --- homeassistant/components/velbus/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index b01c5bb48e17..99bd7149d750 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -11,7 +11,8 @@ "velbus-parser", "velbus-module", "velbus-packet", - "velbus-protocol" + "velbus-protocol", + "velbus-handler" ], "quality_scale": "silver", "requirements": ["velbus-aio==2026.4.1"], From 2e39fdd0e3ff49bb7af535fb23ced5a2318bae92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 9 Jul 2026 14:25:27 +0200 Subject: [PATCH 360/707] Move formatting of aqvify API arguments to lib (#174522) --- homeassistant/components/aqvify/coordinator.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py index 10a77e3d0204..65f064e29c39 100644 --- a/homeassistant/components/aqvify/coordinator.py +++ b/homeassistant/components/aqvify/coordinator.py @@ -201,22 +201,15 @@ class AqvifyAggrDataCoordinator( self.api_client = api_client - @staticmethod - def _get_times() -> tuple[str, str]: - """Determine strings for time parameters for aggregated data from API.""" - date_time_fmt = "%Y-%m-%dT%H:%MZ" - base_time = utcnow() - timedelta(hours=1) - beg_time = base_time.replace(minute=0).strftime(date_time_fmt) - end_time = base_time.replace(minute=59).strftime(date_time_fmt) - return beg_time, end_time - @override async def _async_update_data(self) -> dict[str, AqvifyHourAggregatedValues]: """Fetch device state.""" devices = self.config_entry.runtime_data.coordinator.data.devices device_data: dict[str, AqvifyHourAggregatedValues] = {} - beg_time, end_time = self._get_times() + base_time = utcnow() - timedelta(hours=1) + beg_time = base_time.replace(minute=0, second=0, microsecond=0) + end_time = base_time.replace(minute=59, second=0, microsecond=0) for device in devices.devices.values(): device_key = device.device_key if TYPE_CHECKING: From 22edf156d18410674b53d7aae6b21465c256bf32 Mon Sep 17 00:00:00 2001 From: matthw-labs <69080312+matthw-labs@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:25:48 +0200 Subject: [PATCH 361/707] Enable oscillation for VeSync pedestal fans (LPF-R432S) (#175300) --- homeassistant/components/vesync/fan.py | 42 +++++- tests/components/vesync/common.py | 4 + tests/components/vesync/conftest.py | 23 +++ .../vesync/fixtures/pedestal-fan-detail.json | 33 +++++ .../vesync/fixtures/vesync-devices.json | 15 ++ .../vesync/snapshots/test_binary_sensor.ambr | 37 +++++ .../components/vesync/snapshots/test_fan.ambr | 105 ++++++++++++++ .../vesync/snapshots/test_humidifier.ambr | 37 +++++ .../vesync/snapshots/test_light.ambr | 37 +++++ .../vesync/snapshots/test_sensor.ambr | 37 +++++ .../vesync/snapshots/test_switch.ambr | 133 ++++++++++++++++++ .../vesync/snapshots/test_update.ambr | 99 +++++++++++++ tests/components/vesync/test_fan.py | 60 +++++++- 13 files changed, 654 insertions(+), 8 deletions(-) create mode 100644 tests/components/vesync/fixtures/pedestal-fan-detail.json diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 96ea5feda36a..3d10be4c647f 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -1,7 +1,7 @@ """Support for VeSync fans.""" import logging -from typing import Any, override +from typing import Any, cast, override from pyvesync.base_devices import VeSyncFanBase, VeSyncPurifier @@ -116,7 +116,14 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): ) -> None: """Initialize the fan.""" super().__init__(device, coordinator) - if rgetattr(device, "state.oscillation_status") is not None: + # Tower fans expose a single-axis ``oscillation_status`` state attribute, + # while pedestal fans expose ``vertical_oscillation_status`` and + # ``horizontal_oscillation_status`` separately. The OSCILLATE feature is + # advertised when either form of oscillation is available. + if rgetattr(device, "state.oscillation_status") is not None or ( + rgetattr(device, "state.vertical_oscillation_status") is not None + or rgetattr(device, "state.horizontal_oscillation_status") is not None + ): self._attr_supported_features |= FanEntityFeature.OSCILLATE # Build maps for HA <-> VeSync preset modes self._ha_to_vs_mode_map: dict[str, str] = {} @@ -141,7 +148,14 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): @override def oscillating(self) -> bool: """Return True if device is oscillating.""" - return rgetattr(self.device, "state.oscillation_status") == "on" + # Tower fans report a single-axis oscillation status. + if rgetattr(self.device, "state.oscillation_status") == "on": + return True + # Pedestal fans report vertical and horizontal oscillation separately; + # the fan is considered oscillating when either axis is active. + if rgetattr(self.device, "state.vertical_oscillation_status") == "on": + return True + return rgetattr(self.device, "state.horizontal_oscillation_status") == "on" @property @override @@ -341,14 +355,28 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): @override async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation.""" - if hasattr(self.device, "toggle_oscillation"): - success = await self.device.toggle_oscillation(oscillating) - if not success: + # Pedestal fans expose per-axis oscillation; checked first because + # the inherited ``toggle_oscillation`` is a no-op for them. + if ( + rgetattr(self.device, "state.vertical_oscillation_status") is not None + or rgetattr(self.device, "state.horizontal_oscillation_status") is not None + ): + device = cast(VeSyncFanBase, self.device) + vertical_ok = await device.toggle_vertical_oscillation(oscillating) + horizontal_ok = await device.toggle_horizontal_oscillation(oscillating) + if not vertical_ok or not horizontal_ok: if self.device.last_response: raise HomeAssistantError(self.device.last_response.message) raise HomeAssistantError( "Failed to set oscillation, no response found." ) self.async_write_ha_state() - else: + return + if not hasattr(self.device, "toggle_oscillation"): raise HomeAssistantError("Oscillation not supported by this device.") + success = await self.device.toggle_oscillation(oscillating) + if not success: + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to set oscillation, no response found.") + self.async_write_ha_state() diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 689780a8b19f..07076e1dd8cb 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -14,6 +14,7 @@ ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" ENTITY_FAN = "fan.SmartTowerFan" +ENTITY_PEDESTAL_FAN = "fan.corebreeze_432s" ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" @@ -74,6 +75,9 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/cloud/v1/deviceManaged/deviceDetail", "dimmer-detail.json") ], "SmartTowerFan": [("post", "/cloud/v2/deviceManaged/bypassV2", "fan-detail.json")], + "CoreBreeze 432S": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "pedestal-fan-detail.json") + ], "Humidifier 6000s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-6000s-detail.json") ], diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 8c9e14d0c9e7..7cf61df21e13 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -331,6 +331,29 @@ async def fan_config_entry( return entry +@pytest.fixture(name="pedestal_fan_config_entry") +async def pedestal_fan_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `CoreBreeze 432S`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + unique_id="TESTACCOUNTID", + version=1, + minor_version=3, + ) + entry.add_to_hass(hass) + + device_name = "CoreBreeze 432S" + mock_multiple_device_responses(aioclient_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config diff --git a/tests/components/vesync/fixtures/pedestal-fan-detail.json b/tests/components/vesync/fixtures/pedestal-fan-detail.json new file mode 100644 index 000000000000..b2e88fb5ef09 --- /dev/null +++ b/tests/components/vesync/fixtures/pedestal-fan-detail.json @@ -0,0 +1,33 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "powerSwitch": 0, + "workMode": "normal", + "fanSpeedLevel": 1, + "temperature": 717, + "muteSwitch": 1, + "muteState": 1, + "screenState": 0, + "screenSwitch": 0, + "verticalOscillationState": 1, + "horizontalOscillationState": 1, + "childLock": 0, + "errorCode": 0, + "oscillationCoordinate": null, + "oscillationRange": null, + "sleepPreference": { + "sleepPreferenceType": 0, + "oscillationState": 0, + "fallAsleepRemain": 0, + "initFanSpeedLevel": 0 + } + } + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index 6b9445ceace3..c5d5d91bbcd0 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -281,6 +281,21 @@ "subDeviceList": null, "extension": null, "deviceProp": null + }, + { + "deviceRegion": "US", + "isOwner": true, + "cid": "corebreeze432s", + "deviceType": "LPF-R432S-AEU", + "deviceName": "CoreBreeze 432S", + "deviceImg": "", + "type": "", + "connectionType": "", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-555555555555", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_binary_sensor.ambr b/tests/components/vesync/snapshots/test_binary_sensor.ambr index 81ab44715077..be23b3698d64 100644 --- a/tests/components/vesync/snapshots/test_binary_sensor.ambr +++ b/tests/components/vesync/snapshots/test_binary_sensor.ambr @@ -848,3 +848,40 @@ list([ ]) # --- +# name: test_sensor_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 1ffd9f9744bb..75a1ac7b7f9d 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -883,3 +883,108 @@ list([ ]) # --- +# name: test_fan_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'normal', + 'sleep', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.corebreeze_432s', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vesync', + 'unique_id': 'corebreeze432s', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[CoreBreeze 432S][fan.corebreeze_432s] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_time': None, + 'child_lock': False, + 'display_status': 'off', + : 'CoreBreeze 432S', + 'mode': , + : True, + : 8, + : 8.333333333333334, + : 'normal', + : list([ + 'normal', + 'sleep', + 'turbo', + ]), + : , + }), + 'context': , + 'entity_id': 'fan.corebreeze_432s', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vesync/snapshots/test_humidifier.ambr b/tests/components/vesync/snapshots/test_humidifier.ambr index 4f23f141e1f5..6af8093874bd 100644 --- a/tests/components/vesync/snapshots/test_humidifier.ambr +++ b/tests/components/vesync/snapshots/test_humidifier.ambr @@ -755,3 +755,40 @@ list([ ]) # --- +# name: test_humidifier_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_humidifier_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 3a8e52b76540..a3ac56d74ad8 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -746,3 +746,40 @@ list([ ]) # --- +# name: test_light_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 93753be3c242..d5d334e80f66 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -2183,3 +2183,40 @@ list([ ]) # --- +# name: test_sensor_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index c92aff5a58cd..a1458f6a8a36 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -1420,3 +1420,136 @@ 'state': 'on', }) # --- +# name: test_switch_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.corebreeze_432s_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Display', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'corebreeze432s-display', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.corebreeze_432s_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'corebreeze432s-child_lock', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switch_state[CoreBreeze 432S][switch.corebreeze_432s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'CoreBreeze 432S Display', + }), + 'context': , + 'entity_id': 'switch.corebreeze_432s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[CoreBreeze 432S][switch.corebreeze_432s_child_lock] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'CoreBreeze 432S Child lock', + }), + 'context': , + 'entity_id': 'switch.corebreeze_432s_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vesync/snapshots/test_update.ambr b/tests/components/vesync/snapshots/test_update.ambr index d9decbad21cf..e45b489754aa 100644 --- a/tests/components/vesync/snapshots/test_update.ambr +++ b/tests/components/vesync/snapshots/test_update.ambr @@ -1469,3 +1469,102 @@ 'state': 'on', }) # --- +# --- +# name: test_update_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_update_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.corebreeze_432s_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'corebreeze432s', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_update_state[CoreBreeze 432S][update.corebreeze_432s_firmware] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/vesync/icon.png', + : 'CoreBreeze 432S Firmware', + : False, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.corebreeze_432s_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 326729d12060..03f862088fc4 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -12,7 +12,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, ENTITY_FAN, mock_devices_response +from .common import ( + ALL_DEVICE_NAMES, + ENTITY_FAN, + ENTITY_PEDESTAL_FAN, + mock_devices_response, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -183,6 +188,59 @@ async def test_set_preset_mode( update_mock.assert_called_once() +@pytest.mark.parametrize( + ("action", "api_response", "expectation"), + [ + ("true", True, NoException), + ("false", True, NoException), + ("true", False, pytest.raises(HomeAssistantError)), + ], +) +async def test_pedestal_fan_oscillation( + hass: HomeAssistant, + pedestal_fan_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + action: str, + api_response: bool, + expectation, +) -> None: + """Test oscillation on and off for pedestal fans. + + Pedestal fans (e.g. CoreBreeze 432S / LPF-R432S) expose vertical and + horizontal oscillation as separate toggles. The HA oscillate switch + should control both axes together. + """ + + with ( + expectation, + patch( + "pyvesync.devices.vesyncfan.VeSyncPedestalFan.toggle_vertical_oscillation", + new_callable=AsyncMock, + return_value=api_response, + ) as vertical_mock, + patch( + "pyvesync.devices.vesyncfan.VeSyncPedestalFan." + "toggle_horizontal_oscillation", + new_callable=AsyncMock, + return_value=api_response, + ) as horizontal_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + "oscillate", + {ATTR_ENTITY_ID: ENTITY_PEDESTAL_FAN, "oscillating": action}, + blocking=True, + ) + + await hass.async_block_till_done() + vertical_mock.assert_called_once() + horizontal_mock.assert_called_once() + update_mock.assert_called_once() + + @pytest.mark.parametrize( ("action", "command"), [ From 6009f3ef729e4a44312b2378dc9fc8d0f952a1d6 Mon Sep 17 00:00:00 2001 From: Yardian Support Date: Thu, 9 Jul 2026 20:26:15 +0800 Subject: [PATCH 362/707] Update Yardian switch to use stop_zone (#174558) --- homeassistant/components/yardian/switch.py | 2 +- tests/components/yardian/test_switch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index 853d455844c8..760fc99246c6 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -79,6 +79,6 @@ class YardianSwitch(YardianZoneEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.coordinator.controller.stop_irrigation() + await self.coordinator.controller.stop_zone(self._zone_id) await asyncio.sleep(SWITCH_REFRESH_DELAY) await self.coordinator.async_request_refresh() diff --git a/tests/components/yardian/test_switch.py b/tests/components/yardian/test_switch.py index 07e826f6c10c..f71d1b0d246a 100644 --- a/tests/components/yardian/test_switch.py +++ b/tests/components/yardian/test_switch.py @@ -68,4 +68,4 @@ async def test_turn_off_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_yardian_client.stop_irrigation.assert_called_once() + mock_yardian_client.stop_zone.assert_called_once() From 32c149b7f4e24c9a98f87e9b63da513e841eb154 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:46:12 -0400 Subject: [PATCH 363/707] Reset API on integration reloads for Sonos (#172350) --- homeassistant/components/sonos/__init__.py | 6 +++++- tests/components/sonos/conftest.py | 20 +++++++------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 27d3d3eadc0b..21fb5a88b544 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -15,7 +15,7 @@ from aiohttp import ClientError from requests.exceptions import HTTPError, Timeout from soco import events_asyncio, zonegroupstate import soco.config as soco_config -from soco.core import SoCo +from soco.core import SoCo, soco_reset from soco.events_base import Event as SonosEvent, SubscriptionBase from soco.exceptions import SoCoException import voluptuous as vol @@ -114,6 +114,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool: """Set up Sonos from a config entry.""" + _LOGGER.debug("Setting up Sonos config entry: %s", entry.entry_id) + soco_reset() soco_config.EVENTS_MODULE = events_asyncio soco_config.REQUEST_TIMEOUT = 9.5 soco_config.ZGT_EVENT_FALLBACK = False @@ -153,6 +155,8 @@ async def async_unload_entry( config_entry, PLATFORMS ) await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() + soco_reset() + _LOGGER.debug("Sonos config entry unloaded: %s", config_entry.entry_id) return unload_ok diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index df4a23dd27eb..78ba927837a2 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -8,8 +8,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from soco import SoCo -from soco.alarms import Alarms +from soco import SoCo, soco_reset from soco.data_structures import ( DidlFavorite, DidlMusicTrack, @@ -167,15 +166,12 @@ async def async_autosetup_sonos(async_setup_sonos): await async_setup_sonos() -def reset_sonos_alarms(alarm_event: SonosMockEvent) -> None: - """Reset the Sonos alarms to a known state.""" - sonos_alarms = Alarms() - sonos_alarms.alarms = {} - sonos_alarms._last_zone_used = None - sonos_alarms._last_alarm_list_version = None - sonos_alarms.last_uid = None - sonos_alarms.last_id = 0 - alarm_event.variables["alarm_list_version"] = "RINCON_test:0" +@pytest.fixture(autouse=True) +def reset_sonos(): + """Reset soco state before and after each test.""" + soco_reset() + yield + soco_reset() @pytest.fixture @@ -186,7 +182,6 @@ def async_setup_sonos( async def _wrapper(): config_entry.add_to_hass(hass) - reset_sonos_alarms(alarm_event) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() @@ -953,7 +948,6 @@ async def sonos_setup_two_speakers( """Set up home assistant with two Sonos Speakers.""" soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") - reset_sonos_alarms(alarm_event) await async_setup_component( hass, From a8162782b176a3984606df36ea6fb2d4e7b93f56 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Jul 2026 14:48:26 +0200 Subject: [PATCH 364/707] MELCloud Home add min and max temperature (#174838) --- .../components/melcloud_home/climate.py | 66 ++++++++++++++ .../melcloud_home/snapshots/test_climate.ambr | 8 +- .../components/melcloud_home/test_climate.py | 88 +++++++++++++++++++ 3 files changed, 158 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py index 510881e6bdda..c4fc175d6892 100644 --- a/homeassistant/components/melcloud_home/climate.py +++ b/homeassistant/components/melcloud_home/climate.py @@ -198,6 +198,42 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity): """Return the target temperature.""" return self.unit.set_temperature + @property + @override + def min_temp(self) -> float: + """Return the minimum temperature based on the current HVAC mode.""" + capabilities = self.unit.capabilities + if capabilities is not None: + hvac_mode = self.hvac_mode + if hvac_mode in (HVACMode.COOL, HVACMode.DRY): + if capabilities.min_temp_cool is not None: + return capabilities.min_temp_cool + elif hvac_mode == HVACMode.AUTO: + if capabilities.min_temp_auto is not None: + return capabilities.min_temp_auto + elif hvac_mode == HVACMode.HEAT: + if capabilities.min_temp_heat is not None: + return capabilities.min_temp_heat + return super().min_temp + + @property + @override + def max_temp(self) -> float: + """Return the maximum temperature based on the current HVAC mode.""" + capabilities = self.unit.capabilities + if capabilities is not None: + hvac_mode = self.hvac_mode + if hvac_mode in (HVACMode.COOL, HVACMode.DRY): + if capabilities.max_temp_cool is not None: + return capabilities.max_temp_cool + elif hvac_mode == HVACMode.AUTO: + if capabilities.max_temp_auto is not None: + return capabilities.max_temp_auto + elif hvac_mode == HVACMode.HEAT: + if capabilities.max_temp_heat is not None: + return capabilities.max_temp_heat + return super().max_temp + @property @override def hvac_mode(self) -> HVACMode: @@ -338,6 +374,36 @@ class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity): else self.unit.set_temperature_zone2 ) + @property + @override + def min_temp(self) -> float: + """Return the minimum zone temperature.""" + capabilities = self.unit.capabilities + if capabilities is not None: + value = ( + capabilities.min_set_temperature_zone1 + if self.zone_number == 1 + else capabilities.min_set_temperature_zone2 + ) + if value is not None: + return value + return super().min_temp + + @property + @override + def max_temp(self) -> float: + """Return the maximum zone temperature.""" + capabilities = self.unit.capabilities + if capabilities is not None: + value = ( + capabilities.max_set_temperature_zone1 + if self.zone_number == 1 + else capabilities.max_set_temperature_zone2 + ) + if value is not None: + return value + return super().max_temp + @property @override def hvac_mode(self) -> HVACMode: diff --git a/tests/components/melcloud_home/snapshots/test_climate.ambr b/tests/components/melcloud_home/snapshots/test_climate.ambr index 3d55e0981f43..7c542c400d28 100644 --- a/tests/components/melcloud_home/snapshots/test_climate.ambr +++ b/tests/components/melcloud_home/snapshots/test_climate.ambr @@ -158,8 +158,8 @@ , , ]), - : 35, - : 7, + : 31.0, + : 10.0, : list([ 'auto', 'swing', @@ -231,8 +231,8 @@ , , ]), - : 35, - : 7, + : 31.0, + : 10.0, : , : 'centre', : list([ diff --git a/tests/components/melcloud_home/test_climate.py b/tests/components/melcloud_home/test_climate.py index f728d6d30473..6da75604beac 100644 --- a/tests/components/melcloud_home/test_climate.py +++ b/tests/components/melcloud_home/test_climate.py @@ -438,3 +438,91 @@ async def test_atw_turn_on_off( mock_melcloud_client.control_atw_unit.assert_called_once_with( "atw-unit-uuid-1", **expected_kwargs ) + + +@pytest.mark.parametrize( + ("operation_mode", "expected_min", "expected_max"), + [ + pytest.param("Cool", 16.0, 31.0, id="cool"), + pytest.param("Automatic", 16.0, 31.0, id="auto"), + pytest.param("Fan", 7, 35, id="fan_only"), + ], +) +async def test_ata_temperature_range_by_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + operation_mode: str, + expected_min: float, + expected_max: float, +) -> None: + """Test ATA min/max temperature for cool, auto, and fallback HVAC modes.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + next( + setting + for setting in context["buildings"][0]["airToAirUnits"][0]["settings"] + if setting["name"] == "OperationMode" + )["value"] = operation_mode + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == expected_min + assert state.attributes["max_temp"] == expected_max + + +async def test_ata_no_capabilities_temperature_range( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, +) -> None: + """Test fallback temperature range and hvac_modes when ATA unit has no capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + context["buildings"][0]["airToAirUnits"][0]["capabilities"] = None + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 7 + assert state.attributes["max_temp"] == 35 + assert HVACMode.FAN_ONLY in state.attributes["hvac_modes"] + + +async def test_atw_zone_temperature_range( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, +) -> None: + """Test ATW zone min/max temperature read from unit capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + context["buildings"][0]["airToWaterUnits"][0]["capabilities"].update( + { + "minSetTemperatureZone1": 10.0, + "maxSetTemperatureZone1": 30.0, + "minSetTemperatureZone2": 12.0, + "maxSetTemperatureZone2": 28.0, + } + ) + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state1 = hass.states.get(ATW_ZONE1_ENTITY_ID) + assert state1 is not None + assert state1.attributes["min_temp"] == 10.0 + assert state1.attributes["max_temp"] == 30.0 + + state2 = hass.states.get(ATW_ZONE2_ENTITY_ID) + assert state2 is not None + assert state2.attributes["min_temp"] == 12.0 + assert state2.attributes["max_temp"] == 28.0 From 4be0cdc6446c0d6e567e9a9e58e264b7b4f5a47c Mon Sep 17 00:00:00 2001 From: Sarabveer Singh <4297171+sarabveer@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:56:28 -0400 Subject: [PATCH 365/707] Improve Tesla Wall Connector device info (#176013) --- CODEOWNERS | 4 ++-- .../components/tesla_wall_connector/const.py | 6 +++++- .../components/tesla_wall_connector/entity.py | 13 ++++++++++--- .../tesla_wall_connector/manifest.json | 2 +- .../tesla_wall_connector/test_init.py | 17 ++++++++++++++++- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index bf877ec95f1b..6a78706c143c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1814,8 +1814,8 @@ CLAUDE.md @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 -/homeassistant/components/tesla_wall_connector/ @einarhauks -/tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tesla_wall_connector/ @einarhauks @sarabveer +/tests/components/tesla_wall_connector/ @einarhauks @sarabveer /homeassistant/components/teslemetry/ @Bre77 /tests/components/teslemetry/ @Bre77 /homeassistant/components/tessie/ @Bre77 diff --git a/homeassistant/components/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py index 2a660ee1aae9..9a9d31e6f09f 100644 --- a/homeassistant/components/tesla_wall_connector/const.py +++ b/homeassistant/components/tesla_wall_connector/const.py @@ -8,4 +8,8 @@ WALLCONNECTOR_SERIAL_NUMBER = "serial_number" WALLCONNECTOR_DATA_VITALS = "vitals" WALLCONNECTOR_DATA_LIFETIME = "lifetime" -WALLCONNECTOR_DEVICE_NAME = "Tesla Wall Connector" +WALLCONNECTOR_DEVICE_MANUFACTURER = "Tesla" +WALLCONNECTOR_DEVICE_MODEL = "Wall Connector" +WALLCONNECTOR_DEVICE_NAME = ( + f"{WALLCONNECTOR_DEVICE_MANUFACTURER} {WALLCONNECTOR_DEVICE_MODEL}" +) diff --git a/homeassistant/components/tesla_wall_connector/entity.py b/homeassistant/components/tesla_wall_connector/entity.py index da412aeeeac9..5254654274cb 100644 --- a/homeassistant/components/tesla_wall_connector/entity.py +++ b/homeassistant/components/tesla_wall_connector/entity.py @@ -7,7 +7,12 @@ from typing import Any, override from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME +from .const import ( + DOMAIN, + WALLCONNECTOR_DEVICE_MANUFACTURER, + WALLCONNECTOR_DEVICE_MODEL, + WALLCONNECTOR_DEVICE_NAME, +) from .coordinator import WallConnectorCoordinator, WallConnectorData @@ -43,7 +48,9 @@ class WallConnectorEntity(CoordinatorEntity[WallConnectorCoordinator]): return DeviceInfo( identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, name=WALLCONNECTOR_DEVICE_NAME, - model=self.wall_connector_data.part_number, + manufacturer=WALLCONNECTOR_DEVICE_MANUFACTURER, + model=WALLCONNECTOR_DEVICE_MODEL, + model_id=self.wall_connector_data.part_number, + serial_number=self.wall_connector_data.serial_number, sw_version=self.wall_connector_data.firmware_version, - manufacturer="Tesla", ) diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json index 10d32279cde0..223f6e904d6a 100644 --- a/homeassistant/components/tesla_wall_connector/manifest.json +++ b/homeassistant/components/tesla_wall_connector/manifest.json @@ -1,7 +1,7 @@ { "domain": "tesla_wall_connector", "name": "Tesla Wall Connector", - "codeowners": ["@einarhauks"], + "codeowners": ["@einarhauks", "@sarabveer"], "config_flow": true, "dhcp": [ { diff --git a/tests/components/tesla_wall_connector/test_init.py b/tests/components/tesla_wall_connector/test_init.py index fbb3abc17467..0393bf372473 100644 --- a/tests/components/tesla_wall_connector/test_init.py +++ b/tests/components/tesla_wall_connector/test_init.py @@ -2,13 +2,21 @@ from tesla_wall_connector.exceptions import WallConnectorConnectionError +from homeassistant.components.tesla_wall_connector.const import ( + DOMAIN, + WALLCONNECTOR_DEVICE_MANUFACTURER, + WALLCONNECTOR_DEVICE_MODEL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import create_wall_connector_entry, get_lifetime_mock, get_vitals_mock -async def test_init_success(hass: HomeAssistant) -> None: +async def test_init_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test setup and that we get the device info, including firmware version.""" entry = await create_wall_connector_entry( @@ -16,6 +24,13 @@ async def test_init_success(hass: HomeAssistant) -> None: ) assert entry.state is ConfigEntryState.LOADED + device = device_registry.async_get_device(identifiers={(DOMAIN, "abc123")}) + assert device + assert device.manufacturer == WALLCONNECTOR_DEVICE_MANUFACTURER + assert device.model == WALLCONNECTOR_DEVICE_MODEL + assert device.model_id == "part_123" + assert device.serial_number == "abc123" + assert device.sw_version == "1.2.3" async def test_init_while_offline(hass: HomeAssistant) -> None: From 498c0861e4bf7307cc7aad606ec0195cb466414b Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Thu, 9 Jul 2026 15:07:11 +0200 Subject: [PATCH 366/707] Map ViCare cooling operating mode to HVACMode.COOL (#174490) Co-authored-by: Josef Zweck --- homeassistant/components/vicare/climate.py | 2 + .../vicare/fixtures/Vitocal250A_cooling.json | 6583 +++++++++++++++++ tests/components/vicare/test_climate.py | 37 +- 3 files changed, 6621 insertions(+), 1 deletion(-) create mode 100644 tests/components/vicare/fixtures/Vitocal250A_cooling.json diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 5ae572c9fffb..99ab2235bf96 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -41,6 +41,7 @@ SERVICE_SET_VICARE_MODE = "set_vicare_mode" SERVICE_SET_VICARE_MODE_ATTR_MODE = "vicare_mode" VICARE_MODE_DHW = "dhw" +VICARE_MODE_COOLING = "cooling" VICARE_MODE_HEATING = "heating" VICARE_MODE_HEATINGCOOLING = "heatingCooling" VICARE_MODE_DHWANDHEATING = "dhwAndHeating" @@ -64,6 +65,7 @@ VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = { VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, VICARE_MODE_HEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_HEATING: HVACMode.AUTO, + VICARE_MODE_COOLING: HVACMode.COOL, VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, } diff --git a/tests/components/vicare/fixtures/Vitocal250A_cooling.json b/tests/components/vicare/fixtures/Vitocal250A_cooling.json new file mode 100644 index 000000000000..a8eafbdec8f0 --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal250A_cooling.json @@ -0,0 +1,6583 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.actorSensorTest", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "status": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.actorSensorTest" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.brand", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Viessmann" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.brand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.configuration.houseLocation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "altitude": { + "type": "number", + "unit": "meter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.configuration.houseLocation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.demand.external", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.demand.external" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.lock.external", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.lock.external" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.lock.malfunction", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.lock.malfunction" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.info.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [ + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "I.114", + "priority": "info", + "timestamp": "2025-09-04T10:42:08.000Z" + } + ] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.info.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.service.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.service.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.status.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [ + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.134", + "priority": "status", + "timestamp": "2025-10-05T11:52:33.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.123", + "priority": "status", + "timestamp": "2025-10-05T11:52:30.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.165", + "priority": "status", + "timestamp": "2025-10-05T04:49:42.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.120", + "priority": "status", + "timestamp": "2025-09-04T10:42:08.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.219", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.218", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.217", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.165", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + } + ] + } + }, + "timestamp": "2025-10-05T09:41:49.137Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.status.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.parameterIdentification.version", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "0030.0515.2501.0054" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.parameterIdentification.version" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.consumption.limitation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "14aOff" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.consumption.limitation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.statusReport.consumption", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "limit": { + "type": "number", + "unit": "watt", + "value": 0 + }, + "status": { + "type": "string", + "value": "unlimitedAutonomous" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.statusReport.consumption" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.statusReport.production", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "limit": { + "type": "number", + "unit": "watt", + "value": 0 + }, + "status": { + "type": "string", + "value": "init" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.statusReport.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productMatrix", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "array", + "value": [ + { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + ] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.productMatrix" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.remoteReset", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.remoteReset" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "deviceSerialVitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.setDefaultValues", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.setDefaultValues" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "begin": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + }, + "end": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "device.time.daylightSaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "begin": { + "type": "string", + "value": "25-03" + }, + "end": { + "type": "string", + "value": "25-10" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.type", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "mono" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.type" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.zigbee.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.zigbee.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.zigbee.status", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.zigbee.status" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:41:58.952Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal.target", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal.target" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 33.1 + } + }, + "timestamp": "2025-10-05T10:05:23.125Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.temperature.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:36.387Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.temperature.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.9 + } + }, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.9 + } + }, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 39, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "threshold": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -13, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0.2, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 4 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.6 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "reduced", + "maxEntries": 4, + "modes": ["normal", "comfort"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "mon": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "sat": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "sun": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "thu": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "tue": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "wed": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 39, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["cooling", "heating", "heatingCooling", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "cooling" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normalHeating" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 18 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.humidity.dewpoint", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.humidity.dewpoint" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.8 + } + }, + "timestamp": "2025-10-05T10:09:15.988Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 30 + } + }, + "timestamp": "2025-10-05T09:43:37.884Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 60 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.mode", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.humidity.dewpoint", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.humidity.dewpoint" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": { + "setActive": { + "isExecutable": false, + "name": "setActive", + "params": { + "active": { + "constraints": {}, + "required": true, + "type": "boolean" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0/commands/setActive" + }, + "setPhase": { + "isExecutable": false, + "name": "setPhase", + "params": { + "value": { + "constraints": { + "enum": ["off", "preparing", "not-ready", "ready"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0/commands/setPhase" + } + }, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "ready" + } + }, + "timestamp": "2025-10-05T09:39:40.888Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.heater.crankcase", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.heater.crankcase" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.pressure.inlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 6.66 + } + }, + "timestamp": "2025-10-05T10:09:28.719Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.pressure.inlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.inlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 20.9 + } + }, + "timestamp": "2025-10-05T10:04:30.177Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.inlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.motorChamber", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.2 + } + }, + "timestamp": "2025-10-05T10:00:19.787Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.motorChamber" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.oil", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 41.3 + } + }, + "timestamp": "2025-10-05T10:08:15.229Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.oil" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 25.3 + } + }, + "timestamp": "2025-10-05T10:01:46.601Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.speed.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "revolutionsPerSecond", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:54.797Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.speed.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 8118 + }, + "starts": { + "type": "number", + "unit": "", + "value": 1502 + } + }, + "timestamp": "2025-10-05T09:15:09.300Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.condensors.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 25.9 + } + }, + "timestamp": "2025-10-05T09:50:27.932Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.condensors.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.bufferCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.bufferCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.centralHeatingCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.centralHeatingCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.highDemand.threshold", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.highDemand.threshold", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.highDemand.threshold" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.highDemand.timeframe", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.highDemand.timeframe", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.highDemand.timeframe" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.temperature.comfortCharging", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.temperature.comfortCharging", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.temperature.comfortCharging" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.dhwCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhwCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.heatingRod.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "useApproved": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.heatingRod.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.heatingRod.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "useApproved": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.heatingRod.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.houseHeatingLoad", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "kilowattHour/year", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseHeatingLoad" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.configuration.houseLocation", + "removalDate": "2025-03-15" + }, + "deviceId": "0", + "feature": "heating.configuration.houseLocation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "altitude": { + "type": "number", + "unit": "meter", + "value": 0 + }, + "latitude": { + "type": "number", + "unit": "degree", + "value": 0 + }, + "longitude": { + "type": "number", + "unit": "degree", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseLocation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.houseOrientation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "horizontal": { + "type": "number", + "unit": "degree", + "value": 0 + }, + "vertical": { + "type": "number", + "unit": "degree", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseOrientation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumpOne", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 90 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumpOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumpTwo", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 90 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumpTwo" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumps", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 95 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumps" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.temperature.outside.DampingFactor", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "minute", + "value": 10 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.temperature.outside.DampingFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.time", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:06:38.742Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.device.time" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.variant", + "removalDate": "2025-03-15" + }, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.actuator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.actuator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.highDemand.threshold", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.highDemand.threshold" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.highDemand.timeframe", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.highDemand.timeframe" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.temperature.comfortCharging", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.temperature.comfortCharging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/activate" + }, + "disable": { + "isExecutable": false, + "name": "disable", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/disable" + }, + "enable": { + "isExecutable": true, + "name": "enable", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/enable" + } + }, + "deviceId": "0", + "feature": "heating.dhw.hygiene", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.hygiene.trigger", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene.trigger" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + }, + "setActive": { + "isExecutable": true, + "name": "setActive", + "params": { + "active": { + "constraints": {}, + "required": true, + "type": "boolean" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/setActive" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["efficientWithMinComfort", "efficient", "off"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "efficient" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.balanced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.balanced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficient", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficient" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficientWithMinComfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficientWithMinComfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.off", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.off" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T10:09:08.866Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "mon": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "sat": [ + { + "end": "09:30", + "mode": "on", + "position": 0, + "start": "07:00" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "sun": [ + { + "end": "09:30", + "mode": "on", + "position": 0, + "start": "07:00" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "thu": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "tue": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "wed": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.secondary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.secondary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "mon": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "sat": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "sun": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "thu": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "tue": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "wed": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 46.8 + } + }, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 46.8 + } + }, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.middle", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.tankLoadSystem.return", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.tankLoadSystem.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.tankLoadSystem.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.tankLoadSystem.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hygiene" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 2.5, + "min": 0, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 0 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "default": { + "type": "number", + "unit": "celsius", + "value": 50 + }, + "max": { + "type": "number", + "unit": "celsius", + "value": 10 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 10 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 0, + "efficientUpperBorder": 55, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 55 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.economizers.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.1 + } + }, + "timestamp": "2025-10-05T10:07:59.252Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.economizers.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.heater.base", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.heater.base" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 13.9 + } + }, + "timestamp": "2025-10-05T10:09:04.503Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.sensors.temperature.overheat", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T10:05:56.139Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.sensors.temperature.overheat" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.lock.external", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.external.lock", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.external.lock" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.cooling", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 9.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 61.8 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3382.9 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 389.9 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 96.7 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 5903.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 22.8 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 164.3 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 10024.2 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 183.1 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 206.3 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 15472.4 + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heater.condensatePan", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heater.condensatePan" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heater.fanRing", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heater.fanRing" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.maximumOutsideTemperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.maximumOutsideTemperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3.3 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 28 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 29.5 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 53.6 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 31 + }, + "starts": { + "type": "number", + "unit": "", + "value": 314 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.power.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "ampere", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:48.533Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.power.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.power.output", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:48.533Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.power.output" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.temperature.powerModule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-10-05T10:08:59.620Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.temperature.powerModule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.maxReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.maxReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.notReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.notReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.slightlyReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.slightlyReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.operating.state", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.maxReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.maxReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.maxReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.notReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.notReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.notReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.slightlyReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.slightlyReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.slightlyReduced" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holidayAtHome", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.outdoor.defrosting", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.outdoor.defrosting" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.outdoor.defrosting.thermalEnergy", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:08:59.620Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.outdoor.defrosting.thermalEnergy" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [2.6, 5.8, 3.5, 2.3, 1.8, 5.1, 3.4, 5.4] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 16, 89.7, 28.1, 41.7, 31.5, 54.9, 76.3, 144.2, 178.4, 214.3, 210, + 178.7, 128.4 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [24.5, 32.7, 13.8, 22.4, 12.3] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [875.0999999999999, 1536.8] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [4.6, 6.8, 7, 6.1, 7.8, 4.3, 2.8, 4.6] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 32.3, 32.1, 0, 0, 0, 10.4, 205.7, 538.8, 830.5, 915.9, 839.5, + 560.4000000000001, 208.6 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [39.39999999999999, 22.7, 0, 0, 2.3] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [2565.7, 3809.6] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.cooling", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 2.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 16 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 875.0999999999999 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 89.7 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 24.5 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 1536.8 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 4.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 32.3 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 2565.7 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 32.1 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 39.4 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3809.6 + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 7.199999999999999, 12.6, 10.5, 8.399999999999999, 9.6, + 9.399999999999999, 6.199999999999999, 10 + ] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 48.3, 121.80000000000001, 28.1, 41.7, 31.5, 65.3, 282, 683, 1008.9, + 1130.1999999999998, 1049.5, 739.1000000000001, 337 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 63.89999999999999, 55.400000000000006, 13.8, 22.4, + 14.600000000000001 + ] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [3440.8, 5346.400000000001] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.fans.0.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:44.572Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.fans.0.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.fans.1.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:44.572Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.fans.1.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 13.9 + } + }, + "timestamp": "2025-10-05T09:52:33.577Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.valves.fourThreeWay", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.valves.fourThreeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.dhw", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.heating", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.total", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.operation.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentValue": { + "type": "string", + "value": "standby" + }, + "targetValue": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2025-10-05T09:39:40.888Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.operation.state" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 32.9 + } + }, + "timestamp": "2025-10-05T10:03:11.852Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.temperature.return.minimum", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 5 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.temperature.return.minimum" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.valves.fourThreeWay", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "current": { + "type": "number", + "unit": "percent", + "value": 52 + }, + "target": { + "type": "number", + "unit": "percent", + "value": 50 + } + }, + "timestamp": "2025-10-05T09:42:04.378Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.valves.fourThreeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "connectionType": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.defrosting", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.defrosting" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.dhw.comfortEnsuring", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.dhw.comfortEnsuring" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.heating.comfortEnsuring", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.heating.comfortEnsuring" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.refrigerationCircuitExceeded", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.refrigerationCircuitExceeded" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.runtime", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.runtime" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.screedDrying", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.screedDrying" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.energyFactor", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.energyFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.price.low", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.price.low" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.price.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.price.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.fossil.energyFactor", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.fossil.energyFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.fossil.price.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.fossil.price.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.state", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.state" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.status", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.status" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.temperature.current", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.temperature.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.valves.threeWay", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.valves.threeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.seer.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.seer.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.pressure.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 1.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.pressure.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 33.8 + } + }, + "timestamp": "2025-10-05T10:08:44.151Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.hydraulicSeparator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.hydraulicSeparator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 12.2 + } + }, + "timestamp": "2025-10-05T09:35:59.944Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 34.2 + } + }, + "timestamp": "2025-10-05T09:49:34.529Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.volumetricFlow.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "liter/hour", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:08.629Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.volumetricFlow.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.valves.fourThreeWay.position", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "climatCircuitTwoDefrost" + } + }, + "timestamp": "2025-10-05T09:41:55.621Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.valves.fourThreeWay.position" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "tcu.wifi", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "strength": { + "type": "number", + "unit": "", + "value": -30 + } + }, + "timestamp": "2025-10-05T05:18:18.724Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/tcu.wifi" + } + ] +} diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index ac179456c659..28ae7ae62554 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -6,7 +6,12 @@ import pytest from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ATTR_HVAC_ACTION, HVACAction +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + HVACAction, + HVACMode, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component, entity_registry as er @@ -165,3 +170,33 @@ async def test_hvac_action_multi_compressor_cooling_takes_precedence( assert climate_states, "no climate entity exposed hvac_action" for state in climate_states: assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + + +async def test_hvac_mode_cooling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """hvac_mode maps the ViCare cooling operating mode to HVACMode.COOL.""" + fixtures: list[Fixture] = [ + Fixture(set(), "vicare/Vitocal250A_cooling.json"), + ] + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=MockPyViCare(fixtures).as_vicare_data(), + ), + patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), + ): + await setup_integration(hass, mock_config_entry) + component: entity_component.EntityComponent = hass.data["climate"] + for entity in component.entities: + await entity.async_update_ha_state(force_refresh=True) + + state = hass.states.get("climate.model0_heating") + assert state is not None + assert state.state == HVACMode.COOL + assert HVACMode.COOL in state.attributes[ATTR_HVAC_MODES] From 212ac7ab3a8cb3a3ad04e2dde45243baa83ee8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Thu, 9 Jul 2026 15:23:08 +0200 Subject: [PATCH 367/707] Track nobo_hub connectivity (#170726) --- homeassistant/components/nobo_hub/__init__.py | 16 +++ homeassistant/components/nobo_hub/climate.py | 13 ++- homeassistant/components/nobo_hub/entity.py | 19 +++- .../components/nobo_hub/quality_scale.yaml | 4 +- homeassistant/components/nobo_hub/select.py | 13 ++- homeassistant/components/nobo_hub/sensor.py | 13 ++- tests/components/nobo_hub/__init__.py | 10 ++ tests/components/nobo_hub/conftest.py | 8 ++ tests/components/nobo_hub/test_init.py | 102 +++++++++++++++++- 9 files changed, 178 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 59f3ad1789b8..daf5611f0424 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -1,5 +1,7 @@ """The Nobø Ecohub integration.""" +import logging + from pynobo import nobo from homeassistant.config_entries import ConfigEntry @@ -25,6 +27,8 @@ from .const import ( NOBO_MANUFACTURER, ) +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] type NoboHubConfigEntry = ConfigEntry[nobo] @@ -80,6 +84,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) ) + + def _log_connection_state(_hub: nobo, connected: bool) -> None: + """Log hub connection-state transitions.""" + if connected: + _LOGGER.info("Reconnected to Nobø Ecohub %s", serial) + else: + _LOGGER.info("Lost connection to Nobø Ecohub %s", serial) + + hub.register_connection_callback(_log_connection_state) + entry.async_on_unload( + lambda: hub.deregister_connection_callback(_log_connection_state) + ) entry.runtime_data = hub device_registry = dr.async_get(hass) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 6cb8449a5828..2ddd05e1bbd3 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -161,15 +161,18 @@ class NoboZone(NoboBaseEntity, ClimateEntity): """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py index 22445ed7150e..7ad26edb40bf 100644 --- a/homeassistant/components/nobo_hub/entity.py +++ b/homeassistant/components/nobo_hub/entity.py @@ -17,16 +17,21 @@ class NoboBaseEntity(Entity): def __init__(self, hub: nobo) -> None: """Initialize the entity.""" self._nobo = hub + self._attr_available = hub.connected @override async def async_added_to_hass(self) -> None: - """Register callback with hub.""" + """Register callbacks with hub.""" await super().async_added_to_hass() self._nobo.register_callback(self._handle_hub_update) + self._nobo.register_connection_callback(self._handle_hub_connection) + # Resync in case the state changed between __init__ and callback registration. + self._attr_available = self._nobo.connected @override async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" + """Deregister callbacks from hub.""" + self._nobo.deregister_connection_callback(self._handle_hub_connection) self._nobo.deregister_callback(self._handle_hub_update) await super().async_will_remove_from_hass() @@ -36,6 +41,16 @@ class NoboBaseEntity(Entity): self._read_state() self.async_write_ha_state() + @callback + def _handle_hub_connection(self, _hub: nobo, connected: bool) -> None: + """Handle a connection-state transition from the hub.""" + self._attr_available = connected + if connected: + # Refresh state values so the first state write after reconnect + # carries fresh data, not whatever was cached pre-disconnect. + self._read_state() + self.async_write_ha_state() + @callback def _read_state(self) -> None: """Copy the current hub state from the pynobo client onto the entity attributes. diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index bd6a427a0c28..72de24495175 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -34,9 +34,9 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: status: exempt diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index a4850fc6c76d..9c8313ebdfc5 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -143,15 +143,18 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity): """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True self._profiles = { profile["week_profile_id"]: profile["name"].replace("\xa0", " ") for profile in self._nobo.week_profiles.values() diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index c82718449035..8ebea0b63419 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -69,14 +69,17 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): ) self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the component still exists.""" + return super().available and self._id in self._nobo.components + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.components: - # Component removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. This is a local call.""" + if not self.available: return - self._attr_available = True value = self._nobo.get_current_component_temperature(self._id) self._attr_native_value = None if value is None else float(value) diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index 24f63401951b..d487b000d04d 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -10,3 +10,13 @@ async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: for call in hub.register_callback.call_args_list: call.args[0](hub) await hass.async_block_till_done() + + +async def fire_hub_connection( + hass: HomeAssistant, hub: MagicMock, connected: bool +) -> None: + """Fire the hub's registered connection-state callbacks and wait for state to settle.""" + hub.connected = connected + for call in hub.register_connection_callback.call_args_list: + call.args[0](hub, connected) + await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 7df91c55130e..974e803740c3 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -54,6 +54,12 @@ def config_entry_options() -> dict[str, Any]: return {} +@pytest.fixture +def hub_connected() -> bool: + """Whether the mocked hub reports itself connected after setup.""" + return True + + @pytest.fixture def mock_config_entry( ip_address: str, @@ -75,6 +81,7 @@ def mock_config_entry( @pytest.fixture def mock_nobo_class( connect_exc: BaseException | None, + hub_connected: bool, ) -> Generator[MagicMock]: """Patch the integration's imported `nobo` class with a populated hub instance.""" with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls: @@ -82,6 +89,7 @@ def mock_nobo_class( if connect_exc is not None: hub.connect.side_effect = connect_exc + hub.connected = hub_connected hub.hub_serial = SERIAL hub.hub_info = { "name": "My Eco Hub", diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 7c628c6ec5bb..880aa49dd74f 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -1,5 +1,6 @@ """Tests for the Nobø Ecohub integration setup.""" +import logging from unittest.mock import MagicMock from pynobo import nobo as pynobo_nobo @@ -11,15 +12,23 @@ from homeassistant.components.nobo_hub.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import fire_hub_connection from .conftest import SERIAL, STORED_IP from tests.common import MockConfigEntry NEW_IP = "192.168.1.55" +GLOBAL_ENTITY = "select.my_eco_hub_global_override" + + +@pytest.fixture +def platforms(request: pytest.FixtureRequest) -> list[Platform]: + """Default to select; override per-test via indirect parametrize.""" + return getattr(request, "param", [Platform.SELECT]) async def test_setup_uses_stored_ip( @@ -224,3 +233,94 @@ async def test_setup_registers_hub_device_with_mac( assert device.connections == { (dr.CONNECTION_NETWORK_MAC, "7c:83:06:01:11:92"), } + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_available_when_hub_connected(hass: HomeAssistant) -> None: + """Entities are available when the hub reports connected.""" + state = hass.states.get(GLOBAL_ENTITY) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_unavailable_on_disconnect_and_recovers( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Entities become unavailable on disconnect and recover on reconnect.""" + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(GLOBAL_ENTITY).state == STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_log_on_disconnect_and_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Disconnects and reconnects both log at info level.""" + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, False) + assert any( + record.levelno == logging.INFO + and "Lost connection to Nobø Ecohub" in record.message + for record in caplog.records + ) + + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, True) + assert any( + record.levelno == logging.INFO + and "Reconnected to Nobø Ecohub" in record.message + for record in caplog.records + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_connection_callbacks_deregistered_on_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """Every registered connection callback is deregistered on entry unload.""" + registered = mock_nobo_hub.register_connection_callback.call_count + assert registered > 0 + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_nobo_hub.deregister_connection_callback.call_count == registered + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_zone_removed_during_disconnect_stays_unavailable_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A zone removed via the Nobø app while disconnected stays unavailable on reconnect. + + The connection callback fires before the data callback (pynobo Option C). + Without the `available` property's existence check, the connection callback's + `_attr_available = True` would briefly flip the entity to available before the + data callback's _read_state could re-mark it unavailable. + """ + entity = "climate.living_room_living_room" + assert hass.states.get(entity).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(entity).state == STATE_UNAVAILABLE + + # Simulate the zone being removed via the Nobø app while disconnected: + # by the time the hub reconnects and _get_initial_data runs, hub.zones + # no longer contains the zone. + mock_nobo_hub.zones = {} + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(entity).state == STATE_UNAVAILABLE From 0d6081abffd00188c48ba787198c17366d3c6d3b Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:30:21 -0400 Subject: [PATCH 368/707] Add restore to fan template entities (#175292) --- homeassistant/components/template/fan.py | 71 ++++++- tests/components/template/test_fan.py | 232 ++++++++++++++++++++++- 2 files changed, 300 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index d2c7b3b68150..5a8f221ae98a 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -1,8 +1,9 @@ """Support for Template fans.""" +from dataclasses import asdict, dataclass from enum import StrEnum import logging -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -22,6 +23,7 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import validators as template_validators @@ -155,12 +157,56 @@ def async_create_preview_fan( ) -class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): +@dataclass(kw_only=True) +class FanExtraStoredData(ExtraStoredData): + """Fan extra stored data.""" + + is_on: bool | None + percentage: int | None + preset_mode: str | None + oscillating: bool | None + direction: str | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the fan data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored fan data from a dict.""" + is_on = restored.get("is_on") + percentage = restored.get("percentage") + preset_mode = restored.get("preset_mode") + oscillating = restored.get("oscillating") + direction = restored.get("direction") + if is_on is not None and not isinstance(is_on, bool): + return None + if percentage is not None and not isinstance(percentage, int): + return None + if preset_mode is not None and not isinstance(preset_mode, str): + return None + if oscillating is not None and not isinstance(oscillating, bool): + return None + if direction is not None and not isinstance(direction, str): + return None + return cls( + is_on=is_on, + percentage=percentage, + preset_mode=preset_mode, + oscillating=oscillating, + direction=direction, + ) + + +class AbstractTemplateFan(AbstractTemplateEntity, FanEntity, RestoreEntity): """Representation of a template fan features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _state_option = CONF_STATE + _restore_state_extra_data = FanExtraStoredData + _restore_state_properties = ("_attr_is_on",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -344,6 +390,27 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): ", ".join(_VALID_DIRECTIONS), ) + @property + @override + def extra_restore_state_data(self) -> FanExtraStoredData: + """Return extra state data to be restored.""" + return FanExtraStoredData( + is_on=self._attr_is_on, + percentage=self._attr_percentage, + preset_mode=self._attr_preset_mode, + oscillating=self._attr_oscillating, + direction=self._attr_current_direction, + ) + + @override + def restore_extra_data(self, extra_data: FanExtraStoredData) -> None: + """Restore extra state data.""" + self._attr_is_on = extra_data.is_on + self._attr_percentage = extra_data.percentage + self._attr_preset_mode = extra_data.preset_mode + self._attr_oscillating = extra_data.oscillating + self._attr_current_direction = extra_data.direction + class StateFanEntity(TemplateEntity, AbstractTemplateFan): """A template fan component.""" diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index fc5ab9b78ee8..1c2ad479f9bb 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -26,6 +26,7 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -33,6 +34,8 @@ from .conftest import ( setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry @@ -47,7 +50,7 @@ TEST_FAN = TemplatePlatformSetup( fan.DOMAIN, "test_fan", make_test_trigger( - TEST_INPUT_BOOLEAN, TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY + TEST_AVAILABILITY_ENTITY, TEST_INPUT_BOOLEAN, TEST_STATE_ENTITY_ID ), ) @@ -1366,3 +1369,230 @@ async def test_flow_preview( ) assert state["state"] == STATE_ON + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "state": "{{ state_attr('sensor.test_sensor', 'is_on') }}", + "turn_on": [], + "turn_off": [], + "percentage": "{{ state_attr('sensor.test_sensor', 'percentage') }}", + "set_percentage": [], + "preset_mode": "{{ state_attr('sensor.test_sensor', 'preset_mode') }}", + "set_preset_mode": [], + "preset_modes": ["off", "auto", "low", "medium", "high"], + "oscillating": "{{ state_attr('sensor.test_sensor', 'oscillating') }}", + "set_oscillating": [], + "direction": "{{ state_attr('sensor.test_sensor', 'direction') }}", + "set_direction": [], + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + STATE_ON, + { + "is_on": True, + "percentage": 10, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + STATE_ON, + { + "percentage": 10, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + ), + ( + STATE_OFF, + { + "is_on": False, + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + STATE_OFF, + { + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + ), + ( + STATE_UNAVAILABLE, + { + "is_on": True, + "percentage": 0, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_UNKNOWN, + { + "is_on": False, + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "is_on": "True", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "percentage": "0", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "oscillating": "True", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "preset_mode": 75, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "direction": 75, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring template fan.""" + + restored_attributes = { # These should be ignored + "percentage": 45, + "preset_mode": "high", + "oscillating": True, + "direction": DIRECTION_REVERSE, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_FAN, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_FAN, + style, + config, + f"states('{TEST_STATE_ENTITY_ID}') | float(0) > 10", + ) + + state = assert_state_and_attributes( + hass, + TEST_FAN, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, + "11", + { + "is_on": True, + "percentage": 55, + "preset_mode": "low", + "oscillating": True, + "direction": DIRECTION_REVERSE, + }, + ) + + state = hass.states.get(TEST_FAN.entity_id) + assert state.state == STATE_ON + assert state.attributes["percentage"] == 55 + assert state.attributes["preset_mode"] == "low" + assert state.attributes["oscillating"] is True + assert state.attributes["direction"] == DIRECTION_REVERSE From 145ffc18a3c9a1cdf25ecde5623128d0a410d495 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:36:54 +0200 Subject: [PATCH 369/707] Bump docker/login-action from 4.2.0 to 4.3.0 (#176062) Signed-off-by: dependabot[bot] --- .github/workflows/builder.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3631191edf8d..3975215c7852 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,13 +342,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -521,7 +521,7 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 85b90e2df80d489a20c95a2a264ca368fc928d7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:37:45 +0200 Subject: [PATCH 370/707] Bump github/codeql-action/init from 4.36.2 to 4.36.3 (#176060) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 31ef4c06f05b..bcd736d76eed 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: python From 6006e7d21f0336846e8994376931fa99c42642b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:38:56 +0200 Subject: [PATCH 371/707] Bump github/codeql-action/analyze from 4.36.2 to 4.36.3 (#176063) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bcd736d76eed..080b4e0a1d2f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,6 +33,6 @@ jobs: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:python" From 8dacceea97204ac78e097a4d507562594b6851bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:39:26 +0200 Subject: [PATCH 372/707] Bump docker/metadata-action from 6.1.0 to 6.2.0 (#176059) Signed-off-by: dependabot[bot] --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3975215c7852..d5448e651147 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -378,7 +378,7 @@ jobs: # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev - name: Generate Docker metadata id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ matrix.registry }}/home-assistant sep-tags: "," From 3f01488d1e9936b221f2f9e27e4a8304fe50b5c2 Mon Sep 17 00:00:00 2001 From: inventor7777 <142270525+inventor7777@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:45:19 -0500 Subject: [PATCH 373/707] (Re) add missing WeatherFlow UDP sensors (#171856) --- .../components/weatherflow/icons.json | 9 +++++ .../components/weatherflow/sensor.py | 33 +++++++++++++++++-- .../components/weatherflow/strings.json | 9 +++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/icons.json b/homeassistant/components/weatherflow/icons.json index 6b691f41c9b3..3d16c457cc44 100644 --- a/homeassistant/components/weatherflow/icons.json +++ b/homeassistant/components/weatherflow/icons.json @@ -15,6 +15,15 @@ "lightning_count": { "default": "mdi:lightning-bolt" }, + "lightning_strike_last_distance": { + "default": "mdi:lightning-bolt" + }, + "lightning_strike_last_energy": { + "default": "mdi:lightning-bolt" + }, + "lightning_strike_last_epoch": { + "default": "mdi:lightning-bolt" + }, "precipitation_type": { "default": "mdi:weather-rainy" }, diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 46af3699298f..d83eda960baa 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -10,6 +10,7 @@ from pyweatherflowudp.const import EVENT_RAPID_WIND from pyweatherflowudp.device import ( EVENT_OBSERVATION, EVENT_STATUS_UPDATE, + EVENT_STRIKE, WeatherFlowDevice, WeatherFlowSensorDevice, ) @@ -60,12 +61,13 @@ class WeatherFlowSensorEntityDescription(SensorEntityDescription): raw_data_conv_fn: Callable[[Any], datetime | StateType] + device_attr: str | None = None event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) imperial_suggested_unit: str | None = None def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: """Return the parsed sensor value.""" - if (raw_sensor_data := getattr(device, self.key)) is None: + if (raw_sensor_data := getattr(device, self.device_attr or self.key)) is None: return None return self.raw_data_conv_fn(raw_sensor_data) @@ -153,6 +155,33 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, raw_data_conv_fn=lambda raw_data: raw_data, ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_distance", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_distance", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=2, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.distance.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_energy", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_energy", + state_class=SensorStateClass.MEASUREMENT, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.energy, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_epoch", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_epoch", + device_class=SensorDeviceClass.TIMESTAMP, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.timestamp, + ), WeatherFlowSensorEntityDescription( key="precipitation_type", translation_key="precipitation_type", @@ -310,7 +339,7 @@ async def async_setup_entry( is_metric=(hass.config.units == METRIC_SYSTEM), ) for description in SENSORS - if hasattr(device, description.key) + if hasattr(device, description.device_attr or description.key) ] async_add_entities(sensors) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index d2146e883703..eb5621317094 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -48,6 +48,15 @@ "lightning_count": { "name": "Lightning count" }, + "lightning_strike_last_distance": { + "name": "Lightning last distance" + }, + "lightning_strike_last_energy": { + "name": "Lightning last energy" + }, + "lightning_strike_last_epoch": { + "name": "Lightning last strike" + }, "precipitation_type": { "name": "Precipitation type", "state": { From d32cf459ecf25b9ff57c2972c1eff788809b4570 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Jul 2026 15:50:32 +0200 Subject: [PATCH 374/707] Sanitize add-on name in Supervisor backup filenames (#175267) Co-authored-by: Claude Opus 4.8 --- homeassistant/components/hassio/backup.py | 17 +++++++++++++---- tests/components/hassio/test_backup.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index c800e03b0a8c..6b8dcba7cf99 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -48,14 +48,13 @@ from homeassistant.components.backup import ( RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, - suggested_filename as suggested_backup_filename, suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT @@ -68,6 +67,16 @@ TAG_ADDON_UPDATE = "supervisor.addon_update" _LOGGER = logging.getLogger(__name__) +def _suggested_backup_filename(name: str, date: str) -> str: + """Suggest a filename for a Supervisor backup. + + Slugify the name so a display name with path separators (e.g. an add-on + named "Nabu Casa / Webhook Proxy") can't produce a filename Supervisor + rejects. The unsanitized name is still stored as the backup's display name. + """ + return suggested_filename_from_name_date(slugify(name), date) + + async def async_get_backup_agents( hass: HomeAssistant, **kwargs: Any, @@ -202,7 +211,7 @@ class SupervisorBackupAgent(BackupAgent): stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( location={self.location}, - filename=PurePath(suggested_backup_filename(backup)), + filename=PurePath(_suggested_backup_filename(backup.name, backup.date)), ) async def stream_with_progress() -> AsyncIterator[bytes]: @@ -361,7 +370,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): date = dt_util.now().isoformat() extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} - filename = suggested_filename_from_name_date(backup_name, date) + filename = _suggested_backup_filename(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 193addcfcc29..d65096090a08 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -961,7 +961,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), + filename=PurePath("test_2025-01-30_05.42_12345678.tar"), folders={supervisor_backups.Folder("ssl")}, homeassistant_exclude_database=False, homeassistant=True, @@ -1015,6 +1015,16 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( homeassistant_exclude_database=True, ), ), + ( + {"name": "Nabu Casa / Webhook Proxy for HA MCP"}, + replace( + DEFAULT_BACKUP_OPTIONS, + name="Nabu Casa / Webhook Proxy for HA MCP", + filename=PurePath( + "nabu_casa_webhook_proxy_for_ha_mcp_2025-01-30_05.42_12345678.tar" + ), + ), + ), ], ) async def test_reader_writer_create( @@ -1701,7 +1711,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: - assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") + assert call.args[1].filename == PurePath("test_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations From 68c17fb9278f057f3e896f3b1d41b46a5a805b03 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:50:49 -0400 Subject: [PATCH 375/707] Add restore to device_tracker template entities (#175262) --- .../components/template/device_tracker.py | 52 +++++- .../template/test_device_tracker.py | 148 ++++++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/template/device_tracker.py b/homeassistant/components/template/device_tracker.py index 3c7f013ddbc7..e0103fdf92ca 100644 --- a/homeassistant/components/template/device_tracker.py +++ b/homeassistant/components/template/device_tracker.py @@ -1,7 +1,8 @@ """Support for device trackers which integrates with other components.""" from collections.abc import Callable -from typing import Any +from dataclasses import asdict, dataclass +from typing import Any, Self, override import voluptuous as vol @@ -19,6 +20,7 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -174,10 +176,37 @@ def async_create_preview_tracker( ) -class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity): +@dataclass(kw_only=True) +class TrackerExtraStoredData(ExtraStoredData): + """Holds extra stored data for template tracker entities.""" + + in_zones: list[str] | None + latitude: float | None + longitude: float | None + location_accuracy: float + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the tracker data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self: + """Initialize a stored tracker state from a dict.""" + return cls( + in_zones=restored["in_zones"], + latitude=restored["latitude"], + longitude=restored["longitude"], + location_accuracy=restored["location_accuracy"], + ) + + +class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity, RestoreEntity): """Representation of a template device tracker features.""" _entity_id_format = ENTITY_ID_FORMAT + _restore_state_extra_data = TrackerExtraStoredData + _restore_state_properties = ("_attr_in_zones",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -217,6 +246,25 @@ class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity): """Update the location accuracy.""" self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0 + @property + @override + def extra_restore_state_data(self) -> TrackerExtraStoredData: + """Return tracker specific state data to be restored.""" + return TrackerExtraStoredData( + in_zones=self._attr_in_zones, + latitude=self._attr_latitude, + longitude=self._attr_longitude, + location_accuracy=self._attr_location_accuracy, + ) + + @override + def restore_extra_data(self, extra_data: TrackerExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_in_zones = extra_data.in_zones + self._attr_latitude = extra_data.latitude + self._attr_longitude = extra_data.longitude + self._attr_location_accuracy = extra_data.location_accuracy + class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker): """Representation of a Template device tracker.""" diff --git a/tests/components/template/test_device_tracker.py b/tests/components/template/test_device_tracker.py index b47d249def99..59eab709bf54 100644 --- a/tests/components/template/test_device_tracker.py +++ b/tests/components/template/test_device_tracker.py @@ -21,12 +21,15 @@ from homeassistant.helpers.typing import ConfigType from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry, async_setup_component @@ -35,6 +38,7 @@ from tests.conftest import WebSocketGenerator TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_LATITUDE_ENTITY_ID = "sensor.test_latitude" TEST_LONGITUDE_ENTITY_ID = "sensor.test_longitude" +TEST_LOCATION_ACCURACY_ENTITY_ID = "sensor.test_location_accuracy" TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.availability" TEST_TRACKER = TemplatePlatformSetup( device_tracker.DOMAIN, @@ -43,6 +47,7 @@ TEST_TRACKER = TemplatePlatformSetup( TEST_AVAILABILITY_ENTITY_ID, TEST_LATITUDE_ENTITY_ID, TEST_LONGITUDE_ENTITY_ID, + TEST_LOCATION_ACCURACY_ENTITY_ID, TEST_STATE_ENTITY_ID, ), ) @@ -623,3 +628,146 @@ async def test_flow_preview( assert state["state"] == STATE_NOT_HOME assert state["attributes"]["latitude"] == 10.0 assert state["attributes"]["longitude"] == 40.0 + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "in_zones": "{{ state_attr('sensor.test_state', 'in_zones') }}", + "latitude": "{{ states('sensor.test_latitude') | float(None) }}", + "longitude": "{{ states('sensor.test_longitude') | float(None) }}", + "location_accuracy": "{{ states('sensor.test_location_accuracy') | float(None) }}", + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + STATE_HOME, + { + "in_zones": ["zone.home"], + "latitude": 32.87336, + "longitude": 117.228743, + "location_accuracy": 5.0, + }, + STATE_HOME, + { + "in_zones": ["zone.home"], + "latitude": 32.87336, + "longitude": 117.228743, + "gps_accuracy": 5.0, + }, + ), + ( + STATE_NOT_HOME, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_NOT_HOME, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "gps_accuracy": 10.0, + }, + ), + ( + STATE_UNAVAILABLE, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": None, + "longitude": None, + "gps_accuracy": None, + }, + ), + ( + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": None, + "longitude": None, + "gps_accuracy": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring trigger template device tracker.""" + + restored_attributes = { # These should be ignored + "latitude": 5, + "longitude": 5, + "location_accuracy": 2.0, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_TRACKER, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_TRACKER, + style, + config, + "states('sensor.test_latitude') | float(0) > 19.9", + ) + + state = assert_state_and_attributes( + hass, + TEST_TRACKER, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, "sensor.test_state", "anything", {"in_zones": ["zone.home"]} + ) + await async_trigger(hass, "sensor.test_latitude", "32.88") + await async_trigger(hass, "sensor.test_longitude", "117.24") + await async_trigger(hass, "sensor.test_location_accuracy", "20") + + state = hass.states.get(TEST_TRACKER.entity_id) + assert state.state == STATE_HOME + assert state.attributes["in_zones"] == ["zone.home"] + assert state.attributes["latitude"] == 32.88 + assert state.attributes["longitude"] == 117.24 + assert state.attributes["gps_accuracy"] == 20.0 From a271bac4379424c352f7d03c9b475f9d03c6206f Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:53:55 -0400 Subject: [PATCH 376/707] Add restore to cover template entities (#175216) --- homeassistant/components/template/cover.py | 52 ++++++- tests/components/template/test_cover.py | 169 +++++++++++++++++++++ 2 files changed, 219 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 040887e67132..a61b9cb5e60e 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -1,6 +1,7 @@ """Support for covers which integrate with other components.""" -from typing import TYPE_CHECKING, Any, override +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -22,6 +23,7 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -158,13 +160,40 @@ def async_create_preview_cover( ) -class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): +@dataclass(kw_only=True) +class CoverExtraStoredData(ExtraStoredData): + """Holds extra stored data for template cover entities.""" + + current_cover_position: int | None + current_cover_tilt_position: int | None + is_opening: bool | None + is_closing: bool | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the cover data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self: + """Initialize a stored cover state from a dict.""" + return cls( + current_cover_position=restored["current_cover_position"], + current_cover_tilt_position=restored["current_cover_tilt_position"], + is_opening=restored["is_opening"], + is_closing=restored["is_closing"], + ) + + +class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity, RestoreEntity): """Representation of a template cover features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _extra_optimistic_options = (CONF_POSITION,) _state_option = CONF_STATE + _restore_state_extra_data = CoverExtraStoredData + _restore_state_properties = ("_attr_current_cover_position",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -324,6 +353,25 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): if self._tilt_optimistic: self.async_write_ha_state() + @property + @override + def extra_restore_state_data(self) -> CoverExtraStoredData: + """Return cover specific state data to be restored.""" + return CoverExtraStoredData( + current_cover_position=self._attr_current_cover_position, + current_cover_tilt_position=self._attr_current_cover_tilt_position, + is_opening=self._attr_is_opening, + is_closing=self._attr_is_closing, + ) + + @override + def restore_extra_data(self, extra_data: CoverExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_current_cover_position = extra_data.current_cover_position + self._attr_current_cover_tilt_position = extra_data.current_cover_tilt_position + self._attr_is_opening = extra_data.is_opening + self._attr_is_closing = extra_data.is_closing + class StateCoverEntity(TemplateEntity, AbstractTemplateCover): """Representation of a Template cover.""" diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 0813e96bc461..9fe566ed877f 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -35,6 +35,7 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -42,6 +43,8 @@ from .conftest import ( setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry @@ -49,6 +52,7 @@ from tests.typing import WebSocketGenerator TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_POSITION_ENTITY_ID = "sensor.test_position" +TEST_TILT_POSITION_ENTITY_ID = "sensor.test_tilt_position" TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" TEST_COVER = TemplatePlatformSetup( @@ -57,6 +61,7 @@ TEST_COVER = TemplatePlatformSetup( make_test_trigger( TEST_STATE_ENTITY_ID, TEST_POSITION_ENTITY_ID, + TEST_TILT_POSITION_ENTITY_ID, TEST_AVAILABILITY_ENTITY, ), ) @@ -1102,3 +1107,167 @@ async def test_flow_preview( ) assert state["state"] == CoverState.OPEN + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "position": "{{ states('sensor.test_position') | float(None) }}", + "set_cover_position": [], + "tilt": "{{ states('sensor.test_tilt_position') | float(None) }}", + "set_cover_tilt_position": [], + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + "final_state", + ), + [ + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + CoverState.OPEN, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.OPEN, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": True, + "is_closing": False, + }, + CoverState.OPENING, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.OPENING, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": True, + }, + CoverState.CLOSING, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.CLOSING, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + CoverState.CLOSED, + { + "current_position": 0, + "current_tilt_position": 10, + }, + CoverState.OPEN, + ), + ( + STATE_UNAVAILABLE, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + STATE_UNKNOWN, + { + "current_position": None, + "current_tilt_position": None, + }, + CoverState.OPEN, + ), + ( + STATE_UNKNOWN, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + STATE_UNKNOWN, + { + "current_position": None, + "current_tilt_position": None, + }, + CoverState.OPEN, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: CoverState | str, + saved_extra_data: dict | None, + initial_state: CoverState | str, + initial_attributes: ConfigType, + final_state: CoverState | str, +) -> None: + """Test restoring trigger template weather.""" + + restored_attributes = { # These should be ignored + "current_position": 5, + "current_tilt_position": 5, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_COVER, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_COVER, + style, + config, + "states('sensor.test_position') | float(0) > 50", + ) + + state = assert_state_and_attributes( + hass, + TEST_COVER, + initial_state, + initial_attributes, + ) + + await async_trigger(hass, "sensor.test_position", "75") + await async_trigger(hass, "sensor.test_tilt_position", "75") + + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == final_state + assert state.attributes["current_position"] == 75 + assert state.attributes["current_tilt_position"] == 75 From 0a1c74e07a5fe29f79477d4b9c6c42bc19477e67 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 15:54:48 +0200 Subject: [PATCH 377/707] Namespace llm helper singleton key to avoid domain collision (#176101) Co-authored-by: Claude --- homeassistant/helpers/llm.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 8a23488b7435..49ef6fe81b92 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -51,6 +51,8 @@ ACTION_PARAMETERS_CACHE: HassKey[ dict[str, dict[str, tuple[str | None, vol.Schema]]] ] = HassKey("llm_action_parameters_cache") +APIS_CACHE: HassKey[dict[str, API]] = HassKey("llm_apis") + LLM_API_ASSIST = "assist" @@ -79,7 +81,7 @@ def async_render_no_api_prompt(hass: HomeAssistant) -> str: return "" -@singleton("llm") +@singleton(APIS_CACHE) @callback def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: """Return the registry of LLM APIs. From 8048257f7b4f0581529e9fc06cffef6b5a349445 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Thu, 9 Jul 2026 15:57:03 +0200 Subject: [PATCH 378/707] Ensure Netatmo unique config entry (#176079) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/netatmo/config_flow.py | 6 +----- .../components/netatmo/manifest.json | 3 ++- homeassistant/generated/integrations.json | 3 ++- tests/components/netatmo/test_config_flow.py | 19 +++++++++++++------ 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 0e4c74a6d000..3693bb76105c 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -7,7 +7,7 @@ import uuid import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_SHOW_ON_MAP, CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv @@ -62,10 +62,6 @@ class NetatmoFlowHandler( async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) - - if self.source != SOURCE_REAUTH and self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - return await super().async_step_user(user_input) async def async_step_reauth( diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 6d6aea230f18..83375b245ee2 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==9.4.0"] + "requirements": ["pyatmo==9.4.0"], + "single_config_entry": true } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5a112184ba97..4957ec3ce51a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4652,7 +4652,8 @@ "name": "Netatmo", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "single_config_entry": true }, "netdata": { "name": "Netdata", diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 9773b7029433..743ec43d6130 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -7,7 +7,6 @@ from pyatmo.const import ALL_SCOPES import pytest from homeassistant import config_entries -from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, CONF_WEATHER_AREAS, @@ -35,10 +34,7 @@ VALID_CONFIG = {} async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - flow = config_flow.NetatmoFlowHandler() - flow.hass = hass + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -60,7 +56,18 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" + + +async def test_abort_if_legacy_entry_without_unique_id(hass: HomeAssistant) -> None: + """Check user flow aborts for a legacy entry that has no unique_id yet.""" + MockConfigEntry(domain=DOMAIN, unique_id=None).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" @pytest.mark.usefixtures("current_request_with_host") From cdca91ca28f776de466d48634d3bdb5d94531472 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:17:57 +0200 Subject: [PATCH 379/707] Remove orphan hass.data access in Tankerkoenig (#176112) --- homeassistant/components/tankerkoenig/__init__.py | 4 +--- tests/components/tankerkoenig/conftest.py | 2 +- tests/components/tankerkoenig/test_sensor.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 46387ef34f61..4574f2bc0b45 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -13,8 +13,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: TankerkoenigConfigEntry ) -> bool: """Set a tankerkoenig configuration entry up.""" - hass.data.setdefault(DOMAIN, {}) - coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL) await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/tankerkoenig/conftest.py b/tests/components/tankerkoenig/conftest.py index 1517c3d20604..9966d91dc620 100644 --- a/tests/components/tankerkoenig/conftest.py +++ b/tests/components/tankerkoenig/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import DOMAIN from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/tankerkoenig/test_sensor.py b/tests/components/tankerkoenig/test_sensor.py index 00b507f0015b..b6201034bf31 100644 --- a/tests/components/tankerkoenig/test_sensor.py +++ b/tests/components/tankerkoenig/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component From 991612ad17c48378276cb58388bb4ea7cd737e6f Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Thu, 9 Jul 2026 16:30:43 +0200 Subject: [PATCH 380/707] Drop redundant MotionDetectorPushButton dispatch in HmIP Cloud sensor (#173510) --- .../components/homematicip_cloud/sensor.py | 4 ---- .../homematicip_cloud/test_sensor.py | 24 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 06dd60bfe513..0d2124a7fed6 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -23,7 +23,6 @@ from homematicip.device import ( LightSensor, MotionDetectorIndoor, MotionDetectorOutdoor, - MotionDetectorPushButton, PassageDetector, PresenceDetectorIndoor, RoomControlDeviceAnalog, @@ -217,9 +216,6 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]: MotionDetectorOutdoor: lambda device: [ HomematicipIlluminanceSensor(hap, device), ], - MotionDetectorPushButton: lambda device: [ - HomematicipIlluminanceSensor(hap, device), - ], PresenceDetectorIndoor: lambda device: [ HomematicipIlluminanceSensor(hap, device), ], diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index f09a5b84b884..8ba9773c0d30 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -1,6 +1,7 @@ """Tests for HomematicIP Cloud sensor.""" from homematicip.base.enums import ValveState, WindowState +import pytest from homeassistant.components.homematicip_cloud import DOMAIN from homeassistant.components.homematicip_cloud.entity import ( @@ -340,6 +341,29 @@ async def test_hmip_illuminance_sensor2( assert ha_state.attributes[ATTR_LOWEST_ILLUMINATION] == 785.2 +async def test_hmip_motion_detector_push_button_single_illuminance( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test MotionDetectorPushButton produces exactly one illuminance sensor.""" + await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewegungsmelder für 55er Rahmen – innen"] + ) + illuminance_states = [ + state + for state in hass.states.async_all("sensor") + if state.entity_id.endswith("_illuminance") + ] + assert len(illuminance_states) == 1 + assert ( + illuminance_states[0].entity_id + == "sensor.bewegungsmelder_fur_55er_rahmen_innen_illuminance" + ) + assert illuminance_states[0].state == "14.2" + assert "does not generate unique IDs" not in caplog.text + + async def test_hmip_windspeed_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: From 14121e2ed9c00078e307d48c96c151fc32c3016c Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 9 Jul 2026 16:38:08 +0200 Subject: [PATCH 381/707] Migrate friend accounts to subentries in Steam integration (#175048) --- .../components/steam_online/__init__.py | 33 +- .../components/steam_online/config_flow.py | 217 ++++++----- .../components/steam_online/const.py | 2 + .../components/steam_online/coordinator.py | 16 +- .../components/steam_online/entity.py | 6 +- .../components/steam_online/sensor.py | 33 +- .../components/steam_online/strings.json | 48 ++- tests/components/steam_online/__init__.py | 11 +- tests/components/steam_online/conftest.py | 21 +- .../fixtures/GetPlayerSummariesSingle.json | 22 ++ .../steam_online/snapshots/test_init.ambr | 32 ++ .../steam_online/snapshots/test_sensor.ambr | 67 +++- .../steam_online/test_config_flow.py | 350 ++++++++++++++---- tests/components/steam_online/test_init.py | 48 ++- 14 files changed, 683 insertions(+), 223 deletions(-) create mode 100644 tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json create mode 100644 tests/components/steam_online/snapshots/test_init.ambr diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 8d4464e06c25..335636df105c 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -1,9 +1,13 @@ """The Steam integration.""" +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from .const import CONF_ACCOUNTS, DOMAIN, SUBENTRY_TYPE_FRIEND from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -16,9 +20,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> boo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True +async def _async_update_listener(hass: HomeAssistant, entry: SteamConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -40,4 +51,24 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> b await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) hass.config_entries.async_update_entry(entry, version=2) + if entry.version < 3: + for steamid, name in entry.options[CONF_ACCOUNTS].items(): + if steamid == entry.unique_id: + continue + subentry = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_FRIEND, + title=name, + unique_id=steamid, + data={}, # type: ignore[arg-type] + ) + hass.config_entries.async_add_subentry(entry, subentry) + + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get_device({(DOMAIN, entry.entry_id)}): + if TYPE_CHECKING: + assert entry.unique_id + dev_reg.async_update_device( + device.id, new_identifiers={(DOMAIN, entry.unique_id)} + ) + hass.config_entries.async_update_entry(entry, version=3, options={}) return True diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index fc5313376e80..b70f56b6b4f6 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -1,8 +1,9 @@ """Config flow for Steam integration.""" -from collections.abc import Iterator, Mapping +from collections.abc import Mapping +from itertools import batched import logging -from typing import Any, override +from typing import TYPE_CHECKING, Any, override import steam.api import voluptuous as vol @@ -10,15 +11,21 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlowWithReload, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) -from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, PLACEHOLDERS +from .const import CONF_ACCOUNT, DOMAIN, PLACEHOLDERS, SUBENTRY_TYPE_FRIEND from .coordinator import SteamConfigEntry _LOGGER = logging.getLogger(__name__) @@ -47,16 +54,16 @@ def validate_input(user_input: dict[str, str]) -> dict[str, str | int]: class SteamFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Steam.""" - VERSION = 2 + VERSION = 3 - @staticmethod + @classmethod @callback @override - def async_get_options_flow( - config_entry: SteamConfigEntry, - ) -> SteamOptionsFlowHandler: - """Get the options flow for this handler.""" - return SteamOptionsFlowHandler(config_entry) + def async_get_supported_subentry_types( + cls, config_entry: SteamConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {SUBENTRY_TYPE_FRIEND: FriendSubentryFlowHandler} @override async def async_step_user( @@ -67,6 +74,14 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: await self.async_set_unique_id(user_input[CONF_ACCOUNT]) self._abort_if_unique_id_configured() + + config_entries = self.hass.config_entries.async_entries(DOMAIN) + for entry in config_entries: + if user_input[CONF_ACCOUNT] in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured_as_subentry") + try: res = await self.hass.async_add_executor_job(validate_input, user_input) if res is not None: @@ -83,11 +98,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown exception") errors["base"] = "unknown" if not errors: - return self.async_create_entry( - title=name, - data=user_input, - options={CONF_ACCOUNTS: {user_input[CONF_ACCOUNT]: name}}, - ) + return self.async_create_entry(title=name, data=user_input) user_input = user_input or {} return self.async_show_form( step_id="user", @@ -138,9 +149,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if not errors: - return self.async_update_reload_and_abort( - entry, data_updates=user_input - ) + return self.async_update_and_abort(entry, data_updates=user_input) return self.async_show_form( step_id=( "reauth_confirm" if self.source == SOURCE_REAUTH else SOURCE_RECONFIGURE @@ -153,77 +162,119 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): ) -def _batch_ids(ids: list[str]) -> Iterator[list[str]]: - for i in range(0, len(ids), MAX_IDS_TO_REQUEST): - yield ids[i : i + MAX_IDS_TO_REQUEST] +class FriendSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding a friend.""" + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Subentry user flow.""" + errors: dict[str, str] = {} + config_entry: SteamConfigEntry = self._get_entry() -class SteamOptionsFlowHandler(OptionsFlowWithReload): - """Handle Steam client options.""" + if config_entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="config_entry_not_loaded") - def __init__(self, entry: SteamConfigEntry) -> None: - """Initialize options flow.""" - self.options = dict(entry.options) + client = config_entry.runtime_data.user_interface + if TYPE_CHECKING: + assert config_entry.unique_id - async def async_step_init( - self, user_input: dict[str, dict[str, str]] | None = None - ) -> ConfigFlowResult: - """Manage Steam options.""" if user_input is not None: - for _id in self.options[CONF_ACCOUNTS]: - if _id not in user_input[CONF_ACCOUNTS] and ( - entity_id := er.async_get(self.hass).async_get_entity_id( - Platform.SENSOR, DOMAIN, f"{_id}_account" - ) - ): - er.async_get(self.hass).async_remove(entity_id) - channel_data = { - CONF_ACCOUNTS: { - _id: name - for _id, name in self.options[CONF_ACCOUNTS].items() - if _id in user_input[CONF_ACCOUNTS] - } - } - return self.async_create_entry(title="", data=channel_data) - error = None + config_entries = self.hass.config_entries.async_entries(DOMAIN) + if user_input[CONF_ACCOUNT] in { + entry.unique_id for entry in config_entries + }: + return self.async_abort(reason="already_configured_as_entry") + for entry in config_entries: + if user_input[CONF_ACCOUNT] in { + subentry.unique_id + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND) + }: + return self.async_abort(reason="already_configured") + + try: + title = await self.hass.async_add_executor_job( + lambda: client.GetPlayerSummaries( + steamids=[user_input[CONF_ACCOUNT]] + )["response"]["players"]["player"][0]["personaname"] + ) + except steam.api.HTTPTimeoutError: + errors["base"] = "timeout_connect" + except steam.api.HTTPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unknown exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=title, + data={}, + unique_id=user_input[CONF_ACCOUNT], + ) + + def get_accounts() -> list[dict[str, Any]]: + friends = client.GetFriendList(steamid=config_entry.unique_id)[ + "friendslist" + ]["friends"] + accounts = [] + for steamids in batched( + [friend["steamid"] for friend in friends], + MAX_IDS_TO_REQUEST, + strict=False, + ): + accounts.extend( + client.GetPlayerSummaries(steamids=list(steamids))["response"][ + "players" + ]["player"] + ) + return accounts + try: - users = { - name["steamid"]: name["personaname"] - for name in await self.hass.async_add_executor_job(self.get_accounts) - } - if not users: - error = {"base": "unauthorized"} - + accounts = await self.hass.async_add_executor_job(get_accounts) except steam.api.HTTPTimeoutError: - users = self.options[CONF_ACCOUNTS] + return self.async_abort(reason="timeout_connect") + except steam.api.HTTPError as e: + if "401" in str(e): + me = config_entry.runtime_data.data[config_entry.unique_id] + return self.async_abort( + reason="friendlist_private", + description_placeholders={ + CONF_NAME: me.personaname, + "privacy_settings_url": f"{me.profileurl}edit/settings", + }, + ) + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unknown exception") + return self.async_abort(reason="unknown") - options = { - vol.Required( - CONF_ACCOUNTS, - default=set(self.options[CONF_ACCOUNTS]), - ): cv.multi_select(users | self.options[CONF_ACCOUNTS]), + existing_subentries = { + subentry.unique_id + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND) } - self.options[CONF_ACCOUNTS] = users | self.options[CONF_ACCOUNTS] + options = [ + SelectOptionDict( + value=account["steamid"], + label=account["personaname"], + ) + for account in accounts + if account["steamid"] not in existing_subentries + ] + + if not options: + return self.async_abort(reason="no_more_friends") return self.async_show_form( - step_id="init", data_schema=vol.Schema(options), errors=error + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ACCOUNT): SelectSelector( + SelectSelectorConfig(options=options, sort=True) + ) + } + ), + user_input, + ), + errors=errors, ) - - def get_accounts(self) -> list[dict[str, str | int]]: - """Get accounts.""" - interface = steam.api.interface("ISteamUser") - try: - friends = interface.GetFriendList( - steamid=self.config_entry.data[CONF_ACCOUNT] - ) - _users_str = [user["steamid"] for user in friends["friendslist"]["friends"]] - except steam.api.HTTPError: - return [] - names = [] - for id_batch in _batch_ids(_users_str): - names.extend( - interface.GetPlayerSummaries(steamids=id_batch)["response"]["players"][ - "player" - ] - ) - return names diff --git a/homeassistant/components/steam_online/const.py b/homeassistant/components/steam_online/const.py index 14654da3b765..89d305419b84 100644 --- a/homeassistant/components/steam_online/const.py +++ b/homeassistant/components/steam_online/const.py @@ -35,3 +35,5 @@ STEAM_API_URL = "https://steamcdn-a.akamaihd.net/steam/apps/" STEAM_HEADER_IMAGE_FILE = "header.jpg" STEAM_MAIN_IMAGE_FILE = "capsule_616x353.jpg" STEAM_ICON_URL = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/" + +SUBENTRY_TYPE_FRIEND = "friend" diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 7c3df2b82c4a..ea2f37f11f52 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import timedelta import logging -from typing import override +from typing import TYPE_CHECKING, override import steam.api @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ACCOUNTS, DOMAIN +from .const import DOMAIN, SUBENTRY_TYPE_FRIEND type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] @@ -76,8 +76,16 @@ class SteamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PlayerData]]): def _update(self) -> dict[str, PlayerData]: """Fetch data from API endpoint.""" - accounts = self.config_entry.options[CONF_ACCOUNTS] - _ids = list(accounts) + if TYPE_CHECKING: + assert self.config_entry.unique_id + _ids = [self.config_entry.unique_id] + _ids.extend( + subentry.unique_id + for subentry in self.config_entry.get_subentries_of_type( + SUBENTRY_TYPE_FRIEND + ) + if subentry.unique_id + ) response = self.user_interface.GetPlayerSummaries(steamids=_ids) players = { diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py index be8eebde909b..ac62ce76ca45 100644 --- a/homeassistant/components/steam_online/entity.py +++ b/homeassistant/components/steam_online/entity.py @@ -25,9 +25,9 @@ class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]): self.entity_description = description self._attr_unique_id = f"{steamid}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url="https://store.steampowered.com", + configuration_url=str(coordinator.data[steamid].profileurl), entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, steamid)}, manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, + name=str(coordinator.data[steamid].personaname), ) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 7749c3a5b29b..58190afb580d 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -13,14 +13,14 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( - CONF_ACCOUNTS, STEAM_API_URL, STEAM_HEADER_IMAGE_FILE, STEAM_ICON_URL, STEAM_MAIN_IMAGE_FILE, STEAM_STATUSES, + SUBENTRY_TYPE_FRIEND, ) -from .coordinator import PlayerData, SteamConfigEntry, SteamDataUpdateCoordinator +from .coordinator import PlayerData, SteamConfigEntry from .entity import SteamEntity PARALLEL_UPDATES = 1 @@ -37,7 +37,6 @@ class SteamSensorEntityDescription(SensorEntityDescription): """Steam sensor description.""" value_fn: Callable[[PlayerData], StateType] - name_fn: Callable[[PlayerData], str] entity_picture_fn: Callable[[PlayerData], str] | None = None @@ -46,8 +45,8 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( key=SteamSensor.ACCOUNT, translation_key=SteamSensor.ACCOUNT, value_fn=lambda x: STEAM_STATUSES[x.personastate], - name_fn=lambda x: x.personaname, entity_picture_fn=lambda x: x.avatarfull, + name=None, ), ) @@ -61,28 +60,28 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - SteamSensorEntity(coordinator, steamid, description) - for steamid in entry.options[CONF_ACCOUNTS] + SteamSensorEntity(coordinator, entry.unique_id, description) for description in SENSOR_DESCRIPTIONS - if steamid in coordinator.data + if entry.unique_id is not None and entry.unique_id in coordinator.data ) + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND): + async_add_entities( + [ + SteamSensorEntity(coordinator, subentry.unique_id, description) + for description in SENSOR_DESCRIPTIONS + if subentry.unique_id is not None + and subentry.unique_id in coordinator.data + ], + config_subentry_id=subentry.subentry_id, + ) + class SteamSensorEntity(SteamEntity, SensorEntity): """Representation of a Steam sensor entity.""" entity_description: SteamSensorEntityDescription - def __init__( - self, - coordinator: SteamDataUpdateCoordinator, - steamid: str, - description: SteamSensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, steamid, description) - self._attr_name = self.entity_description.name_fn(coordinator.data[steamid]) - @property @override def native_value(self) -> StateType: diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json index 4d51800a505e..1d9289147bab 100644 --- a/homeassistant/components/steam_online/strings.json +++ b/homeassistant/components/steam_online/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_configured_as_subentry": "This Steam account is already configured as a sub-entry.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, @@ -46,6 +47,41 @@ } } }, + "config_subentries": { + "friend": { + "abort": { + "already_configured": "Already configured as a friend in this or another account.", + "already_configured_as_entry": "This account is already configured as a service and cannot be added as a friend.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "config_entry_not_loaded": "Cannot add friend accounts when the main account is disabled or not loaded.", + "friendlist_private": "Your friend list appears to be private or inaccessible.\n\nTo add friend accounts open Steam and go to [**{name} > Profile > Edit Profile > Privacy Settings**]({privacy_settings_url}) and set **Friends List** to **Public**.\n\nOnce your friends are added, you can switch it back to your preferred privacy setting.", + "no_more_friends": "All friends from your friend list have already been added.", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Friend", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add friend" + }, + "step": { + "user": { + "data": { + "account": "Friend" + }, + "data_description": { + "account": "Select a friend from your friend list to track their online status." + }, + "description": "Track the online status of a Steam friend.", + "title": "Friend online status" + } + } + } + }, "entity": { "sensor": { "account": { @@ -80,17 +116,5 @@ "timeout_exception": { "message": "Failed to connect to Steam due to a request timeout" } - }, - "options": { - "error": { - "unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends" - }, - "step": { - "init": { - "data": { - "accounts": "Names of accounts to be monitored" - } - } - } } } diff --git a/tests/components/steam_online/__init__.py b/tests/components/steam_online/__init__.py index 51c0d5a2a579..2208159f033e 100644 --- a/tests/components/steam_online/__init__.py +++ b/tests/components/steam_online/__init__.py @@ -1,6 +1,6 @@ """Tests for Steam integration.""" -from homeassistant.components.steam_online.const import CONF_ACCOUNT, CONF_ACCOUNTS +from homeassistant.components.steam_online.const import CONF_ACCOUNT from homeassistant.const import CONF_API_KEY API_KEY = "abc123" @@ -13,12 +13,3 @@ CONF_DATA = { CONF_API_KEY: API_KEY, CONF_ACCOUNT: ACCOUNT_1, } - -CONF_OPTIONS = {CONF_ACCOUNTS: {ACCOUNT_1: ACCOUNT_NAME_1}} - -CONF_OPTIONS_2 = { - CONF_ACCOUNTS: { - ACCOUNT_1: ACCOUNT_NAME_1, - ACCOUNT_2: ACCOUNT_NAME_2, - } -} diff --git a/tests/components/steam_online/conftest.py b/tests/components/steam_online/conftest.py index a71782eda153..44630ada87c4 100644 --- a/tests/components/steam_online/conftest.py +++ b/tests/components/steam_online/conftest.py @@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from homeassistant.components.steam_online.const import DOMAIN +from homeassistant.components.steam_online.const import DOMAIN, SUBENTRY_TYPE_FRIEND +from homeassistant.config_entries import ConfigSubentryData -from . import ACCOUNT_1, CONF_DATA, CONF_OPTIONS +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_2, CONF_DATA from tests.common import MockConfigEntry, load_json_object_fixture, patch @@ -18,9 +19,16 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data=CONF_DATA, - options=CONF_OPTIONS, unique_id=ACCOUNT_1, - version=2, + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ), + ], + version=3, ) @@ -42,6 +50,11 @@ def mock_steam_api() -> Generator[MagicMock]: "homeassistant.components.steam_online.config_flow.steam.api.interface" ) as mock_client, patch("homeassistant.components.steam_online.config_flow.steam.api.key.set"), + patch( + "homeassistant.components.steam_online.coordinator.steam.api.interface", + new=mock_client, + ), + patch("homeassistant.components.steam_online.coordinator.steam.api.key.set"), patch( "homeassistant.components.steam_online.config_flow.MAX_IDS_TO_REQUEST", 2 ), diff --git a/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json b/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json new file mode 100644 index 000000000000..dae685459f42 --- /dev/null +++ b/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json @@ -0,0 +1,22 @@ +{ + "response": { + "players": { + "player": [ + { + "steamid": "12345678912345678", + "communityvisibilitystate": 1, + "profilestate": 1, + "personaname": "testaccount2", + "profileurl": "https://steamcommunity.com/profiles/987654321/", + "avatar": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg", + "avatarmedium": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg", + "avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg", + "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", + "lastlogoff": 1775409487, + "personastate": 2, + "personastateflags": 0 + } + ] + } + } +} diff --git a/tests/components/steam_online/snapshots/test_init.ambr b/tests/components/steam_online/snapshots/test_init.ambr new file mode 100644 index 000000000000..62d11b8f6b6d --- /dev/null +++ b/tests/components/steam_online/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://steamcommunity.com/profiles/123456789/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'steam_online', + '12345678901234567', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Steam', + 'model': None, + 'model_id': None, + 'name': 'testaccount1', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/steam_online/snapshots/test_sensor.ambr b/tests/components/steam_online/snapshots/test_sensor.ambr index 26e5aa3a1b0b..23261040871d 100644 --- a/tests/components/steam_online/snapshots/test_sensor.ambr +++ b/tests/components/steam_online/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.steam_testaccount1-entry] +# name: test_sensors[sensor.testaccount1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.steam_testaccount1', + 'entity_id': 'sensor.testaccount1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -21,12 +21,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'testaccount1', + 'object_id_base': None, 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'testaccount1', + 'original_name': None, 'platform': 'steam_online', 'previous_unique_id': None, 'suggested_object_id': None, @@ -36,11 +36,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.steam_testaccount1-state] +# name: test_sensors[sensor.testaccount1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', - : 'Steam testaccount1', + : 'testaccount1', 'game': 'The Witcher: Enhanced Edition', 'game_icon': 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg', 'game_id': '20900', @@ -50,10 +50,63 @@ 'level': 10, }), 'context': , - 'entity_id': 'sensor.steam_testaccount1', + 'entity_id': 'sensor.testaccount1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'online', }) # --- +# name: test_sensors[sensor.testaccount2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_account', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + : 'testaccount2', + 'last_online': datetime.datetime(2026, 4, 5, 10, 18, 7, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'level': 10, + }), + 'context': , + 'entity_id': 'sensor.testaccount2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'busy', + }) +# --- diff --git a/tests/components/steam_online/test_config_flow.py b/tests/components/steam_online/test_config_flow.py index 9c22c9ab09ef..950c0f493e27 100644 --- a/tests/components/steam_online/test_config_flow.py +++ b/tests/components/steam_online/test_config_flow.py @@ -6,23 +6,19 @@ from unittest.mock import AsyncMock, MagicMock import pytest import steam.api -from homeassistant.components.steam_online.const import CONF_ACCOUNTS, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY +from homeassistant.components.steam_online.const import ( + CONF_ACCOUNT, + DOMAIN, + SUBENTRY_TYPE_FRIEND, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er -from . import ( - ACCOUNT_1, - ACCOUNT_2, - ACCOUNT_NAME_1, - CONF_DATA, - CONF_OPTIONS, - CONF_OPTIONS_2, -) +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_1, ACCOUNT_NAME_2, API_KEY, CONF_DATA -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.mark.usefixtures("steam_api") @@ -46,7 +42,6 @@ async def test_flow_user( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == ACCOUNT_NAME_1 assert result["data"] == CONF_DATA - assert result["options"] == CONF_OPTIONS assert result["result"].unique_id == ACCOUNT_1 assert len(mock_setup_entry.mock_calls) == 1 @@ -94,7 +89,6 @@ async def test_flow_user_errors( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == ACCOUNT_NAME_1 assert result["data"] == CONF_DATA - assert result["options"] == CONF_OPTIONS assert result["result"].unique_id == ACCOUNT_1 assert len(mock_setup_entry.mock_calls) == 1 @@ -120,6 +114,30 @@ async def test_flow_user_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("steam_api") +async def test_flow_user_already_configured_as_subentry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test user initialized flow with duplicate account.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: API_KEY, + CONF_ACCOUNT: ACCOUNT_2, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_subentry" + + @pytest.mark.usefixtures("steam_api") async def test_flow_reauth( hass: HomeAssistant, @@ -188,103 +206,297 @@ async def test_flow_reauth_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("steam_api") -async def test_options_flow( - hass: HomeAssistant, - config_entry: MockConfigEntry, -) -> None: - """Test updating options.""" - config_entry.add_to_hass(hass) +async def test_add_friend_flow(hass: HomeAssistant, steam_api: MagicMock) -> None: + """Test add friend subentry flow.""" + steam_api.return_value.GetPlayerSummaries.return_value = ( + await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN + ) + ) + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1, ACCOUNT_2]}, + user_input={CONF_ACCOUNT: ACCOUNT_2}, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS_2 + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ) + } @pytest.mark.usefixtures("steam_api") -async def test_options_flow_deselect( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - config_entry: MockConfigEntry, +async def test_add_friend_flow_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test deselecting user.""" - config_entry.add_to_hass(hass) + """Test add friend subentry flow aborts if friend is already configured as subentry.""" + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + data={CONF_ACCOUNT: ACCOUNT_2}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("steam_api") +async def test_add_friend_flow_already_configured_as_entry(hass: HomeAssistant) -> None: + """Test add friend subentry flow aborts if friend is already configured as config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_2, + data=CONF_DATA, + unique_id=ACCOUNT_2, + version=3, + ).add_to_hass(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_ACCOUNTS: []}, + user_input={CONF_ACCOUNT: ACCOUNT_2}, ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_entry" + + +@pytest.mark.parametrize( + ("side_effect", "reason", "description_placeholders"), + [ + ( + steam.api.HTTPError("Server connection failed: Unauthorized (401)"), + "friendlist_private", + { + CONF_NAME: ACCOUNT_NAME_1, + "privacy_settings_url": "https://steamcommunity.com/profiles/123456789/edit/settings", + }, + ), + ( + steam.api.HTTPError, + "cannot_connect", + None, + ), + ( + steam.api.HTTPTimeoutError, + "timeout_connect", + None, + ), + ( + ValueError, + "unknown", + None, + ), + ], +) +async def test_add_friend_flow_abort_errors( + hass: HomeAssistant, + steam_api: MagicMock, + config_entry: MockConfigEntry, + side_effect: type[Exception] | Exception, + reason: str, + description_placeholders: dict[str, str] | None, +) -> None: + """Test add friend subentry flow aborts on errors.""" + + steam_api.return_value.GetFriendList.side_effect = side_effect + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_ACCOUNTS: {}} - assert len(entity_registry.entities) == 0 + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + assert result["description_placeholders"] == description_placeholders -async def test_options_flow_timeout( +async def test_add_friend_flow_abort_no_more_friends( hass: HomeAssistant, - config_entry: MockConfigEntry, steam_api: MagicMock, + config_entry: MockConfigEntry, ) -> None: - """Test updating options timeout getting friends list.""" + """Test add friend subentry flow aborts when no more friends left to add.""" + + steam_api.return_value.GetPlayerSummaries.return_value = ( + await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN + ) + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_more_friends" + + +@pytest.mark.usefixtures("steam_api") +async def test_add_friend_flow_config_entry_not_loaded( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test add friend subentry flow.""" config_entry.add_to_hass(hass) - steam_api.return_value.GetFriendList.side_effect = steam.api.HTTPTimeoutError - result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1]}, + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "config_entry_not_loaded" -async def test_options_flow_unauthorized( +@pytest.mark.parametrize( + ("side_effect", "error_msg"), + [ + (steam.api.HTTPTimeoutError, "timeout_connect"), + (steam.api.HTTPError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_add_friend_errors( hass: HomeAssistant, - config_entry: MockConfigEntry, steam_api: MagicMock, + side_effect: type[Exception], + error_msg: str, ) -> None: - """Test updating options when user's friends list is not public.""" - config_entry.add_to_hass(hass) - steam_api.return_value.GetFriendList.side_effect = steam.api.HTTPError - result = await hass.config_entries.options.async_init(config_entry.entry_id) + """Test add friend subentry flow with recoverable errors.""" - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1]}, + player_summaries = await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN ) + steam_api.return_value.GetPlayerSummaries.return_value = player_summaries + + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + steam_api.return_value.GetPlayerSummaries.side_effect = [ + side_effect, + player_summaries, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT: ACCOUNT_2}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_msg} + + steam_api.return_value.GetPlayerSummaries.side_effect = None + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT: ACCOUNT_2}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ) + } @pytest.mark.usefixtures("steam_api") diff --git a/tests/components/steam_online/test_init.py b/tests/components/steam_online/test_init.py index 89ea46f6a00b..61339d066f97 100644 --- a/tests/components/steam_online/test_init.py +++ b/tests/components/steam_online/test_init.py @@ -4,14 +4,19 @@ from unittest.mock import MagicMock import pytest import steam.api +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.steam_online.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.steam_online.const import ( + CONF_ACCOUNTS, + DOMAIN, + SUBENTRY_TYPE_FRIEND, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ACCOUNT_1, ACCOUNT_NAME_1, CONF_DATA, CONF_OPTIONS +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_1, ACCOUNT_NAME_2, CONF_DATA from tests.common import MockConfigEntry @@ -97,6 +102,7 @@ async def test_device_info( hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test device info.""" config_entry.add_to_hass(hass) @@ -104,28 +110,26 @@ async def test_device_info( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert ( - device := device_registry.async_get_device( - identifiers={(DOMAIN, config_entry.entry_id)} - ) + device_registry.async_get_device(identifiers={(DOMAIN, ACCOUNT_1)}) == snapshot ) - assert device.configuration_url == "https://store.steampowered.com" - assert device.entry_type == dr.DeviceEntryType.SERVICE - assert device.identifiers == {(DOMAIN, config_entry.entry_id)} - assert device.manufacturer == DEFAULT_NAME - assert device.name == DEFAULT_NAME - @pytest.mark.usefixtures("steam_api") async def test_migrate_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test entry migration.""" config_entry = MockConfigEntry( domain=DOMAIN, data=CONF_DATA, - options=CONF_OPTIONS, + options={ + CONF_ACCOUNTS: { + ACCOUNT_1: ACCOUNT_NAME_1, + ACCOUNT_2: ACCOUNT_NAME_2, + } + }, unique_id=ACCOUNT_1, version=1, ) @@ -142,12 +146,30 @@ async def test_migrate_entry( original_name=ACCOUNT_NAME_1, ) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, config_entry.entry_id)}, + ) + assert sensor.unique_id == f"sensor.steam_{ACCOUNT_1}" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.version == 2 + assert config_entry.version == 3 assert (sensor := entity_registry.async_get(sensor.entity_id)) assert sensor.unique_id == f"{ACCOUNT_1}_account" + + assert (device := device_registry.async_get(device.id)) + assert device.identifiers == {(DOMAIN, ACCOUNT_1)} + + assert len(config_entry.subentries) == 1 + subentries = list(config_entry.subentries.values()) + assert subentries[0].unique_id == ACCOUNT_2 + assert subentries[0].title == ACCOUNT_NAME_2 + assert subentries[0].subentry_type == SUBENTRY_TYPE_FRIEND + + assert config_entry.options == {} + + assert device_registry.async_get_device(identifiers={(DOMAIN, ACCOUNT_2)}) From 79339016a7e29addb5ea307e14fc2e52f44b7754 Mon Sep 17 00:00:00 2001 From: greengreenblue <47041862+greengreenblue@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:43:12 -0500 Subject: [PATCH 382/707] Add swing mode select entities for gree integration (#169169) --- homeassistant/components/gree/climate.py | 113 +++++++++---- homeassistant/components/gree/strings.json | 33 ++++ .../gree/snapshots/test_climate.ambr | 59 +++++-- tests/components/gree/test_climate.py | 160 +++++++++++++++--- 4 files changed, 291 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 2e39f2e8226b..773bdd40899e 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -26,10 +26,6 @@ from homeassistant.components.climate import ( PRESET_ECO, PRESET_NONE, PRESET_SLEEP, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -57,7 +53,7 @@ HVAC_MODES = { Mode.Fan: HVACMode.FAN_ONLY, Mode.Heat: HVACMode.HEAT, } -HVAC_MODES_REVERSE = {v: k for k, v in HVAC_MODES.items()} +HVAC_MODES_INVERSE = {v: k for k, v in HVAC_MODES.items()} PRESET_MODES = [ PRESET_ECO, # Power saving mode @@ -75,9 +71,38 @@ FAN_MODES = { FanSpeed.MediumHigh: FAN_MEDIUM_HIGH, FanSpeed.High: FAN_HIGH, } -FAN_MODES_REVERSE = {v: k for k, v in FAN_MODES.items()} +FAN_MODES_INVERSE = {v: k for k, v in FAN_MODES.items()} -SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] +VERTICAL_SWING_MODES: dict[str, VerticalSwing] = { + "default": VerticalSwing.Default, + "full_swing": VerticalSwing.FullSwing, + "fixed_upper": VerticalSwing.FixedUpper, + "fixed_upper_middle": VerticalSwing.FixedUpperMiddle, + "fixed_middle": VerticalSwing.FixedMiddle, + "fixed_lower_middle": VerticalSwing.FixedLowerMiddle, + "fixed_lower": VerticalSwing.FixedLower, + "swing_upper": VerticalSwing.SwingUpper, + "swing_upper_middle": VerticalSwing.SwingUpperMiddle, + "swing_middle": VerticalSwing.SwingMiddle, + "swing_lower_middle": VerticalSwing.SwingLowerMiddle, + "swing_lower": VerticalSwing.SwingLower, +} +VERTICAL_SWING_MODES_INVERSE: dict[VerticalSwing, str] = { + v: k for k, v in VERTICAL_SWING_MODES.items() +} + +HORIZONTAL_SWING_MODES: dict[str, HorizontalSwing] = { + "default": HorizontalSwing.Default, + "full_swing": HorizontalSwing.FullSwing, + "left": HorizontalSwing.Left, + "left_center": HorizontalSwing.LeftCenter, + "center": HorizontalSwing.Center, + "right_center": HorizontalSwing.RightCenter, + "right": HorizontalSwing.Right, +} +HORIZONTAL_SWING_MODES_INVERSE: dict[HorizontalSwing, str] = { + v: k for k, v in HORIZONTAL_SWING_MODES.items() +} async def async_setup_entry( @@ -109,15 +134,18 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = TARGET_TEMPERATURE_STEP - _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF] + _attr_hvac_modes = [*HVAC_MODES_INVERSE, HVACMode.OFF] _attr_preset_modes = PRESET_MODES - _attr_fan_modes = [*FAN_MODES_REVERSE] - _attr_swing_modes = SWING_MODES + _attr_fan_modes = [*FAN_MODES_INVERSE] + _attr_swing_modes = [*VERTICAL_SWING_MODES] + _attr_swing_horizontal_modes = [*HORIZONTAL_SWING_MODES] _attr_name = None + _attr_translation_key = "climate" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_min_temp = TEMP_MIN _attr_max_temp = TEMP_MAX @@ -189,7 +217,7 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): if not self.coordinator.device.power: self.coordinator.device.power = True - self.coordinator.device.mode = HVAC_MODES_REVERSE.get(hvac_mode) + self.coordinator.device.mode = HVAC_MODES_INVERSE.get(hvac_mode) await self.coordinator.push_state_update() self.async_write_ha_state() @@ -264,47 +292,60 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if fan_mode not in FAN_MODES_REVERSE: + if fan_mode not in FAN_MODES_INVERSE: raise ValueError(f"Invalid fan mode: {fan_mode}") - self.coordinator.device.fan_speed = FAN_MODES_REVERSE.get(fan_mode) + self.coordinator.device.fan_speed = FAN_MODES_INVERSE.get(fan_mode) await self.coordinator.push_state_update() self.async_write_ha_state() @property @override - def swing_mode(self) -> str: - """Return the current swing mode for the device.""" - h_swing = self.coordinator.device.horizontal_swing == HorizontalSwing.FullSwing - v_swing = self.coordinator.device.vertical_swing == VerticalSwing.FullSwing - - if h_swing and v_swing: - return SWING_BOTH - if h_swing: - return SWING_HORIZONTAL - if v_swing: - return SWING_VERTICAL - return SWING_OFF + def swing_mode(self) -> str | None: + """Return the current vertical swing mode for the device.""" + try: + return VERTICAL_SWING_MODES_INVERSE.get( + VerticalSwing(self.coordinator.device.vertical_swing) + ) + except ValueError: + return None @override async def async_set_swing_mode(self, swing_mode: str) -> None: - """Set new target swing operation.""" - if swing_mode not in SWING_MODES: - raise ValueError(f"Invalid swing mode: {swing_mode}") - + """Set new target vertical swing operation.""" _LOGGER.debug( - "Setting swing mode to %s for device %s", + "Setting vertical swing mode to %s for device %s", swing_mode, self._attr_name, ) - self.coordinator.device.horizontal_swing = HorizontalSwing.Center - self.coordinator.device.vertical_swing = VerticalSwing.FixedMiddle - if swing_mode in (SWING_BOTH, SWING_HORIZONTAL): - self.coordinator.device.horizontal_swing = HorizontalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_VERTICAL): - self.coordinator.device.vertical_swing = VerticalSwing.FullSwing + self.coordinator.device.vertical_swing = VERTICAL_SWING_MODES[swing_mode] + await self.coordinator.push_state_update() + self.async_write_ha_state() + @property + @override + def swing_horizontal_mode(self) -> str | None: + """Return the current horizontal swing mode for the device.""" + try: + return HORIZONTAL_SWING_MODES_INVERSE.get( + HorizontalSwing(self.coordinator.device.horizontal_swing) + ) + except ValueError: + return None + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + _LOGGER.debug( + "Setting horizontal swing mode to %s for device %s", + swing_horizontal_mode, + self._attr_name, + ) + + self.coordinator.device.horizontal_swing = HORIZONTAL_SWING_MODES[ + swing_horizontal_mode + ] await self.coordinator.push_state_update() self.async_write_ha_state() diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index 153919fb0dce..cbf1cef49405 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -11,6 +11,39 @@ } }, "entity": { + "climate": { + "climate": { + "state_attributes": { + "swing_horizontal_mode": { + "state": { + "center": "Center", + "default": "Default", + "full_swing": "Full swing", + "left": "Left", + "left_center": "Left center", + "right": "Right", + "right_center": "Right center" + } + }, + "swing_mode": { + "state": { + "default": "Default", + "fixed_lower": "Fixed lower", + "fixed_lower_middle": "Fixed lower middle", + "fixed_middle": "Fixed middle", + "fixed_upper": "Fixed upper", + "fixed_upper_middle": "Fixed upper middle", + "full_swing": "Full swing", + "swing_lower": "Swing lower", + "swing_lower_middle": "Swing lower middle", + "swing_middle": "Swing middle", + "swing_upper": "Swing upper", + "swing_upper_middle": "Swing upper middle" + } + } + } + } + }, "switch": { "fresh_air": { "name": "Fresh air" diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 2d7d974e5cdd..c5fd7f80825c 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -32,13 +32,31 @@ 'none', 'sleep', ]), - : , - : 'off', + : , + : 'default', + : list([ + 'default', + 'full_swing', + 'left', + 'left_center', + 'center', + 'right_center', + 'right', + ]), + : 'default', : list([ - 'off', - 'vertical', - 'horizontal', - 'both', + 'default', + 'full_swing', + 'fixed_upper', + 'fixed_upper_middle', + 'fixed_middle', + 'fixed_lower_middle', + 'fixed_lower', + 'swing_upper', + 'swing_upper_middle', + 'swing_middle', + 'swing_lower_middle', + 'swing_lower', ]), : 1, : 25, @@ -85,11 +103,28 @@ 'none', 'sleep', ]), + : list([ + 'default', + 'full_swing', + 'left', + 'left_center', + 'center', + 'right_center', + 'right', + ]), : list([ - 'off', - 'vertical', - 'horizontal', - 'both', + 'default', + 'full_swing', + 'fixed_upper', + 'fixed_upper_middle', + 'fixed_middle', + 'fixed_lower_middle', + 'fixed_lower', + 'swing_upper', + 'swing_upper_middle', + 'swing_middle', + 'swing_lower_middle', + 'swing_lower', ]), : 1, }), @@ -117,8 +152,8 @@ 'platform': 'gree', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'climate', 'unique_id': 'aabbcc112233', 'unit_of_measurement': None, }), diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index d38044e9bdf2..539bb6b923d3 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -20,6 +20,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, @@ -34,18 +35,17 @@ from homeassistant.components.climate import ( SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_VERTICAL, HVACMode, ) from homeassistant.components.gree.climate import ( - FAN_MODES_REVERSE, + FAN_MODES_INVERSE, + HORIZONTAL_SWING_MODES_INVERSE, HVAC_MODES, - HVAC_MODES_REVERSE, + HVAC_MODES_INVERSE, + VERTICAL_SWING_MODES_INVERSE, GreeClimateEntity, ) from homeassistant.components.gree.const import ( @@ -425,7 +425,7 @@ async def test_send_target_temperature( hass.config.units = units device().power = True - device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) + device().mode = HVAC_MODES_INVERSE.get(HVACMode.AUTO) fake_device = device() if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: @@ -700,7 +700,7 @@ async def test_update_hvac_mode( ) -> None: """Test for updating hvac mode from the device.""" device().power = hvac_mode != HVACMode.OFF - device().mode = HVAC_MODES_REVERSE.get(hvac_mode) + device().mode = HVAC_MODES_INVERSE.get(hvac_mode) await async_setup_gree(hass) @@ -778,7 +778,7 @@ async def test_update_fan_mode( hass: HomeAssistant, discovery, device, fan_mode ) -> None: """Test for updating fan mode from the device.""" - device().fan_speed = FAN_MODES_REVERSE.get(fan_mode) + device().fan_speed = FAN_MODES_INVERSE.get(fan_mode) await async_setup_gree(hass) @@ -788,10 +788,11 @@ async def test_update_fan_mode( @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "swing_mode", + ["default", "full_swing", "fixed_upper", "fixed_lower"], ) async def test_send_swing_mode( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, swing_mode: str ) -> None: """Test for sending swing mode command to the device.""" await async_setup_gree(hass) @@ -826,10 +827,11 @@ async def test_send_invalid_swing_mode(hass: HomeAssistant, discovery, device) - @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "swing_mode", + ["default", "full_swing", "fixed_upper", "fixed_lower"], ) async def test_send_swing_mode_device_timeout( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, swing_mode: str ) -> None: """Test for sending swing mode command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -849,28 +851,134 @@ async def test_send_swing_mode_device_timeout( @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "vertical_swing", + [VerticalSwing.Default, VerticalSwing.FullSwing, VerticalSwing.FixedUpper], ) async def test_update_swing_mode( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, vertical_swing: VerticalSwing ) -> None: """Test for updating swing mode from the device.""" - device().horizontal_swing = ( - HorizontalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_HORIZONTAL) - else HorizontalSwing.Default - ) - device().vertical_swing = ( - VerticalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_VERTICAL) - else VerticalSwing.Default - ) + device().vertical_swing = vertical_swing await async_setup_gree(hass) state = hass.states.get(ENTITY_ID) assert state is not None - assert state.attributes.get(ATTR_SWING_MODE) == swing_mode + assert ( + state.attributes.get(ATTR_SWING_MODE) + == VERTICAL_SWING_MODES_INVERSE[vertical_swing] + ) + + +@pytest.mark.parametrize( + "swing_horizontal_mode", + ["default", "full_swing", "left", "right"], +) +async def test_send_swing_horizontal_mode( + hass: HomeAssistant, discovery, device, swing_horizontal_mode: str +) -> None: + """Test for sending horizontal swing mode command to the device.""" + await async_setup_gree(hass) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == swing_horizontal_mode + + +async def test_send_invalid_swing_horizontal_mode( + hass: HomeAssistant, discovery, device +) -> None: + """Test for sending an invalid horizontal swing mode command to the device.""" + await async_setup_gree(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: "invalid"}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) != "invalid" + + +@pytest.mark.parametrize( + "swing_horizontal_mode", + ["default", "full_swing", "left", "right"], +) +async def test_send_swing_horizontal_mode_device_timeout( + hass: HomeAssistant, discovery, device, swing_horizontal_mode: str +) -> None: + """Test for sending horizontal swing mode command to the device with a device timeout.""" + device().push_state_update.side_effect = DeviceTimeoutError + + await async_setup_gree(hass) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == swing_horizontal_mode + + +@pytest.mark.parametrize( + "horizontal_swing", + [HorizontalSwing.Default, HorizontalSwing.FullSwing, HorizontalSwing.Left], +) +async def test_update_swing_horizontal_mode( + hass: HomeAssistant, discovery, device, horizontal_swing: HorizontalSwing +) -> None: + """Test for updating horizontal swing mode from the device.""" + device().horizontal_swing = horizontal_swing + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert ( + state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) + == HORIZONTAL_SWING_MODES_INVERSE[horizontal_swing] + ) + + +async def test_swing_mode_unknown_device_value( + hass: HomeAssistant, discovery, device +) -> None: + """Test that an out-of-range vertical swing value from the device returns None.""" + device().vertical_swing = 99 + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_MODE) is None + + +async def test_swing_horizontal_mode_unknown_device_value( + hass: HomeAssistant, discovery, device +) -> None: + """Test that an out-of-range horizontal swing value from the device returns None.""" + device().horizontal_swing = 99 + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) is None async def test_coordinator_update_handler( From 834cb45ef1d19e385f5042ad2f91c3736a0e8cca Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 10 Jul 2026 00:47:02 +1000 Subject: [PATCH 383/707] Trigger reauth on Teslemetry LoginRequired errors (#174255) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/teslemetry/__init__.py | 11 +++++++++++ homeassistant/components/teslemetry/strings.json | 3 +++ tests/components/teslemetry/test_init.py | 2 ++ 3 files changed, 16 insertions(+) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 26669ce64bf9..4f8a8a06c295 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -10,6 +10,7 @@ from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, InvalidToken, + LoginRequired, SubscriptionRequired, TeslaFleetError, ) @@ -265,6 +266,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - translation_domain=DOMAIN, translation_key="auth_failed_invalid_token", ) from e + except LoginRequired as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed_login_required", + ) from e except SubscriptionRequired as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -402,6 +408,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - translation_domain=DOMAIN, translation_key="auth_failed_invalid_token", ) from e + except LoginRequired as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed_login_required", + ) from e except SubscriptionRequired as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index e7f9f6dff25f..84957296f254 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1131,6 +1131,9 @@ "auth_failed_invalid_token": { "message": "Access token is invalid, please reauthenticate" }, + "auth_failed_login_required": { + "message": "Login is no longer valid, please reauthenticate" + }, "auth_failed_migration": { "message": "Failed to migrate to OAuth, please reauthenticate" }, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 947998c1db10..3598dc5da597 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -13,6 +13,7 @@ from tesla_fleet_api.exceptions import ( InsufficientCredits, InvalidResponse, InvalidToken, + LoginRequired, RateLimited, SubscriptionRequired, TeslaFleetError, @@ -61,6 +62,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), + (LoginRequired, ConfigEntryState.SETUP_ERROR), (SubscriptionRequired, ConfigEntryState.SETUP_ERROR), (TeslaFleetError, ConfigEntryState.SETUP_RETRY), ] From 9bec46df3dffad6a9b7486c1a51d0b701b118720 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:50:17 +0200 Subject: [PATCH 384/707] Add Kiosker Reauthentication (#174440) --- .../components/kiosker/config_flow.py | 43 +++++++++++ .../components/kiosker/manifest.json | 2 +- .../components/kiosker/quality_scale.yaml | 2 +- homeassistant/components/kiosker/strings.json | 14 +++- tests/components/kiosker/test_config_flow.py | 72 +++++++++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 85e43f280e03..9e2d51ab6d8c 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the Kiosker integration.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -37,6 +38,11 @@ STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } +) async def validate_input( @@ -121,6 +127,43 @@ class KioskerConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + config_data = { + **reauth_entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + } + validation_errors, device_id = await validate_input(self.hass, config_data) + if validation_errors: + errors.update(validation_errors) + else: + assert device_id is not None + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + description_placeholders={"name": reauth_entry.title}, + errors=errors, + ) + @override async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index fc8c2ed911fa..7256cda13b22 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/kiosker", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["kiosker-python-api==1.2.9"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 92531353def7..cd3cf4e2bb63 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -34,7 +34,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 2e700a3a43eb..ea117b290b8a 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_device": "The device does not match the configured device." }, "error": { "bad_request": "Invalid request. Check your configuration.", @@ -13,6 +15,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings." + }, + "description": "Re-authenticate {name} with Home Assistant. Generate a new API token in the Kiosker app settings.", + "title": "Re-authenticate Kiosker" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 4dd0a57ca205..72ab7740bebb 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -275,3 +275,75 @@ async def test_user_flow_no_device_id( ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow updates the token and reloads.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "new-token"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "new-token" + + +async def test_reauth_flow_wrong_device( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow aborts when a different device is found at the host.""" + mock_config_entry.add_to_hass(hass) + mock_kiosker_api.status.return_value.device_id = "DIFFERENT-DEVICE-ID" + + result = await mock_config_entry.start_reauth_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "new-token"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "wrong_device" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param(ConnectionError, "cannot_connect", id="connection_error"), + pytest.param(AuthenticationError, "invalid_auth", id="auth_error"), + pytest.param(IPAuthenticationError, "invalid_ip_auth", id="ip_auth_error"), + pytest.param(TLSVerificationError, "tls_error", id="tls_error"), + pytest.param(BadRequestError, "bad_request", id="bad_request"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test reauth flow shows correct error for each API exception.""" + mock_config_entry.add_to_hass(hass) + mock_kiosker_api.status.side_effect = side_effect + + result = await mock_config_entry.start_reauth_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "bad-token"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} From 1b64480c48ca2d899798072ae891f73064c13992 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 16:51:40 +0200 Subject: [PATCH 385/707] Fix flaky remote_calendar coordinator refresh test (#176078) Co-authored-by: Claude --- tests/components/remote_calendar/test_calendar.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index 8fa54e50bfba..cf074cc1d77e 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -565,13 +565,7 @@ async def test_coordinator_refresh_updates_upcoming_event_state( """ ) route = respx.get(CALENDER_URL).mock( - side_effect=[ - Response(status_code=200, text=original_calendar), - # We currently update the calendar twice on startup, tracked - # in issue #148315 - Response(status_code=200, text=original_calendar), - Response(status_code=200, text=updated_calendar), - ] + return_value=Response(status_code=200, text=original_calendar) ) await setup_integration(hass, config_entry) @@ -580,10 +574,10 @@ async def test_coordinator_refresh_updates_upcoming_event_state( assert state.attributes.get("start_time") == "2026-05-18 06:40:00" # Advance clock to trigger the next update interval + route.return_value = Response(status_code=200, text=updated_calendar) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY) assert state assert state.attributes.get("start_time") == "2026-05-19 08:00:00" - assert route.call_count == 3 From 705f5152db8c1f79801a02ffd42099e4ce71a617 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Jul 2026 17:16:26 +0200 Subject: [PATCH 386/707] Refresh hassio coordinator after OS update install (#176004) Co-authored-by: Claude Fable 5 --- homeassistant/components/hassio/update.py | 1 + tests/components/hassio/test_update.py | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index ff5d25ba7df3..afe62408ef55 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -282,6 +282,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): ) -> None: """Install an update.""" await update_os(self.hass, version, backup) + await self.coordinator.async_refresh() @override async def async_release_notes(self) -> str | None: diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 6a066cb703d7..6dbd236771e4 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -523,7 +523,9 @@ async def test_update_addon_with_backup_removes_old_backups( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) -async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: +async def test_update_os( + hass: HomeAssistant, supervisor_client: AsyncMock, os_info: AsyncMock +) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -537,7 +539,15 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N assert result await hass.async_block_till_done() - supervisor_client.os.update.return_value = None + async def mock_os_update(*args: Any) -> None: + """Simulate Supervisor reporting the new version after the update.""" + os_info.return_value = replace( + os_info.return_value, + version="1.0.0dev2222", + update_available=False, + ) + + supervisor_client.os.update.side_effect = mock_os_update with patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", ) as mock_create_backup: @@ -550,6 +560,13 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N mock_create_backup.assert_not_called() supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) + # The coordinator is refreshed after install so the new version + # shows up immediately + state = hass.states.get("update.home_assistant_operating_system_update") + assert state is not None + assert state.state == "off" + assert state.attributes["installed_version"] == "1.0.0dev2222" + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), From 7a91656f1402f4cd919a7ac541f5ba48eb14a1ff Mon Sep 17 00:00:00 2001 From: matt123p Date: Thu, 9 Jul 2026 16:18:22 +0100 Subject: [PATCH 387/707] Fix pause before Assist response on ESPHome Assist satelites (#173712) --- .../components/esphome/assist_satellite.py | 65 ++-- .../components/esphome/wav_parser.py | 146 ++++++++ .../esphome/test_assist_satellite.py | 319 ++++++++++++++++++ tests/components/esphome/test_wav_parser.py | 270 +++++++++++++++ 4 files changed, 773 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/esphome/wav_parser.py create mode 100644 tests/components/esphome/test_wav_parser.py diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index ce7c3037c9e6..0182cacf9cfb 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -4,14 +4,12 @@ import asyncio from collections.abc import AsyncIterable from functools import partial import hashlib -import io from itertools import chain import json import logging from pathlib import Path import socket from typing import Any, cast, override -import wave from aioesphomeapi import ( MediaPlayerFormatPurpose, @@ -53,6 +51,7 @@ from .entity import EsphomeAssistEntity, convert_api_error_ha_error from .entry_data import ESPHomeConfigEntry from .enum_mapper import EsphomeEnumMapper from .ffmpeg_proxy import async_create_proxy_url +from .wav_parser import stream_wav PARALLEL_UPDATES = 0 @@ -726,36 +725,41 @@ class EsphomeAssistSatellite( ) return - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + seconds_in_chunk = samples_per_chunk / sample_rate + start_time: float | None = None + audio_duration_sent = 0.0 - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - if ( - (wav_file.getframerate() != sample_rate) - or (wav_file.getsampwidth() != sample_width) - or (wav_file.getnchannels() != sample_channels) - ): - _LOGGER.error("Can only stream 16Khz 16-bit mono WAV") - return + async for chunk, is_last in stream_wav( + tts_result.async_stream_result(), + expected_format="pcm", + expected_channels=sample_channels, + expected_width=sample_width, + expected_sample_rate=sample_rate, + samples_per_chunk=samples_per_chunk, + ): + if not self._is_running: + break # type: ignore[unreachable] - _LOGGER.debug("Streaming %s audio samples", wav_file.getnframes()) + if start_time is None: + start_time = asyncio.get_running_loop().time() - while self._is_running: - chunk = wav_file.readframes(samples_per_chunk) - if not chunk: - break + self._send_tts_audio(chunk) - if self._udp_server is not None: - self._udp_server.send_audio_bytes(chunk) - else: - self.cli.send_voice_assistant_audio(chunk) + audio_duration_sent += seconds_in_chunk - # Wait for 90% of the duration of the audio that was - # sent for it to be played. This will overrun the - # device's buffer for very long audio, so using a media - # player is preferred. - samples_in_chunk = len(chunk) // (sample_width * sample_channels) - seconds_in_chunk = samples_in_chunk / sample_rate - await asyncio.sleep(seconds_in_chunk * 0.9) + if is_last: + break + + # The ring buffer in the remote device is fixed at 512ms. + # We want to keep it at around 384ms (75% full) to prevent + # the buffer from overflowing or underflowing. + assert start_time is not None + elapsed = asyncio.get_running_loop().time() - start_time + if (wait_time := (audio_duration_sent - 0.384) - elapsed) > 0: + await asyncio.sleep(wait_time) + + except ValueError as err: + _LOGGER.error("Error streaming WAV: %s", err) except asyncio.CancelledError: return # Don't trigger state change finally: @@ -767,6 +771,13 @@ class EsphomeAssistSatellite( self.tts_response_finished() self._entry_data.async_set_assist_pipeline_state(False) + def _send_tts_audio(self, payload: bytes) -> None: + """Send TTS audio via API or UDP.""" + if self._udp_server is not None: + self._udp_server.send_audio_bytes(payload) + else: + self.cli.send_voice_assistant_audio(payload) + async def _wrap_audio_stream(self) -> AsyncIterable[bytes]: """Yield audio chunks from the queue until None.""" while True: diff --git a/homeassistant/components/esphome/wav_parser.py b/homeassistant/components/esphome/wav_parser.py new file mode 100644 index 000000000000..892c89287f9f --- /dev/null +++ b/homeassistant/components/esphome/wav_parser.py @@ -0,0 +1,146 @@ +"""Helper to parse and stream WAV files.""" + +from collections.abc import AsyncIterable, AsyncIterator +import struct + + +class WAVHeaderParser: + """Helper to parse WAV headers from a byte buffer.""" + + def __init__( + self, + expected_channels: int, + expected_width: int, + expected_sample_rate: int, + ) -> None: + """Initialize the WAV header parser.""" + self.expected_channels = expected_channels + self.expected_width = expected_width + self.expected_sample_rate = expected_sample_rate + self.riff_checked = False + self.fmt_validated = False + self.data_bytes_remaining = 0 + self.found_data = False + + def parse(self, bytes_buffer: bytearray) -> bool: + """Parse headers from the buffer. Returns True if headers are fully parsed.""" + while True: + if not self.riff_checked: + if len(bytes_buffer) < 12: + return False + riff, _, wave_fmt = struct.unpack("<4sI4s", bytes_buffer[:12]) + if riff != b"RIFF" or wave_fmt != b"WAVE": + raise ValueError("Invalid WAV format: missing RIFF/WAVE header") + self.riff_checked = True + del bytes_buffer[:12] + + if len(bytes_buffer) < 8: + return False + + chunk_id, chunk_size = struct.unpack("<4sI", bytes_buffer[:8]) + + if chunk_id == b"fmt ": + if len(bytes_buffer) < 8 + chunk_size + (chunk_size & 1): + return False + + if chunk_size < 16: + raise ValueError(f"WAV fmt chunk too small: {chunk_size} bytes") + + ( + audio_format, + num_channels, + chunk_sample_rate, + _, + _, + bits_per_sample, + ) = struct.unpack(" AsyncIterator[tuple[bytes, bool]]: + """Parse a WAV stream, validate its header, and yield chunks of audio data.""" + if expected_format != "pcm": + raise ValueError(f"Unsupported expected format: {expected_format}") + + parser = WAVHeaderParser(expected_channels, expected_width, expected_sample_rate) + bytes_buffer = bytearray() + bytes_per_chunk_payload = samples_per_chunk * expected_width * expected_channels + pending_chunk: bytes | None = None + + async for chunk in stream: + bytes_buffer.extend(chunk) + + if not parser.found_data and not parser.parse(bytes_buffer): + continue + + while ( + parser.data_bytes_remaining >= bytes_per_chunk_payload + and len(bytes_buffer) >= bytes_per_chunk_payload + ): + payload = bytes(bytes_buffer[:bytes_per_chunk_payload]) + del bytes_buffer[:bytes_per_chunk_payload] + parser.data_bytes_remaining -= bytes_per_chunk_payload + + if pending_chunk is not None: + yield pending_chunk, False + + pending_chunk = payload + + if parser.data_bytes_remaining == 0: + yield pending_chunk, True + pending_chunk = None + return + + if not parser.found_data: + raise ValueError("Invalid WAV format: incomplete or missing data chunk") + + remaining_bytes_to_read = min(parser.data_bytes_remaining, len(bytes_buffer)) + if remaining_bytes_to_read > 0: + remaining = bytes(bytes_buffer[:remaining_bytes_to_read]) + if pending_chunk is not None: + yield pending_chunk, False + pending_chunk = remaining + + if pending_chunk is not None: + yield pending_chunk, True diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 8c495552ac0e..c83fc9d55b3b 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -5,6 +5,7 @@ from dataclasses import replace from http import HTTPStatus import io import socket +import struct from unittest.mock import ANY, AsyncMock, Mock, patch import wave @@ -2428,3 +2429,321 @@ async def test_multichannel_audio_fallback_channel_0( await satellite.handle_audio(b"channel 0", b"channel 1") await satellite.handle_pipeline_stop(abort=False) await pipeline_finished.wait() + + +def _make_wav_header( + riff: bytes = b"RIFF", + wave_fmt: bytes = b"WAVE", + chunk_id: bytes = b"fmt ", + chunk_size: int = 16, + audio_format: int = 1, + num_channels: int = 1, + sample_rate: int = 16000, + bits_per_sample: int = 16, + data_chunk_id: bytes = b"data", + data_chunk_size: int = 0, +) -> bytes: + """Build a WAV header for testing.""" + header = struct.pack("<4sI4s", riff, 36 + data_chunk_size, wave_fmt) + header += struct.pack("<4sI", chunk_id, chunk_size) + if chunk_size >= 16: + block_align = num_channels * (bits_per_sample // 8) + byte_rate = sample_rate * block_align + header += struct.pack( + " 16: + header += b"\x00" * (chunk_size - 16) + header += struct.pack("<4sI", data_chunk_id, data_chunk_size) + return header + + +class _ChunkedMockResultStream(MockResultStream): + """MockResultStream that yields pre-defined chunks.""" + + def __init__( + self, hass: HomeAssistant, extension: str, chunks: list[bytes] + ) -> None: + super().__init__(hass, extension, b"") + self.chunks = chunks + + async def async_stream_result(self): + for chunk in self.chunks: + yield chunk + + +@pytest.mark.parametrize( + "wav_data", + [ + pytest.param( + _make_wav_header(riff=b"RIF_"), + id="invalid_riff_header", + ), + pytest.param( + _make_wav_header(audio_format=2), + id="unsupported_audio_format", + ), + pytest.param( + _make_wav_header(num_channels=2), + id="incorrect_channels", + ), + pytest.param( + _make_wav_header(sample_rate=22050), + id="incorrect_sample_rate", + ), + pytest.param( + _make_wav_header(bits_per_sample=8), + id="incorrect_bits_per_sample", + ), + pytest.param( + struct.pack("<4sI4s", b"RIFF", 20, b"WAVE") + + struct.pack("<4sI", b"data", 0), + id="missing_fmt_chunk", + ), + pytest.param( + struct.pack("<4sI4s", b"RIFF", 100, b"WAVE") + + struct.pack("<4sI", b"fmt ", 8) + + b"\x00" * 8 + + struct.pack("<4sI", b"data", 4) + + b"\x01\x02\x03\x04", + id="fmt_chunk_too_small", + ), + ], +) +async def test_stream_tts_audio_invalid_wav( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + wav_data: bytes, +) -> None: + """Test that invalid WAV headers are rejected without sending audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + stream = _ChunkedMockResultStream(hass, "wav", [wav_data]) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_not_called() + + +async def test_stream_tts_audio_junk_chunk_skipping( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test skipping unknown chunks in _stream_tts_audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Skipping unknown chunks + junk_chunk = struct.pack("<4sI", b"JUNK", 4) + b"junk" + header_with_junk = ( + struct.pack("<4sI4s", b"RIFF", 100, b"WAVE") + + junk_chunk + + struct.pack("<4sI", b"fmt ", 16) + + struct.pack(" None: + """Test fragmentation of incoming stream bytes.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + full_wav = _make_wav_header(data_chunk_size=4) + b"\x01\x02\x03\x04" + byte_chunks = [bytes([b]) for b in full_wav] + stream = _ChunkedMockResultStream(hass, "wav", byte_chunks) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_called_once_with(b"\x01\x02\x03\x04") + + +async def test_stream_tts_audio_multi_chunk_pacing( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test multi-chunk streaming with sleep/wait logic.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + wav_with_16000bytes = _make_wav_header(data_chunk_size=16000) + b"\x00" * 16000 + stream = _ChunkedMockResultStream(hass, "wav", [wav_with_16000bytes]) + await satellite._stream_tts_audio(stream, samples_per_chunk=512) + assert mock_client.send_voice_assistant_audio.call_count == 16 + for i in range(15): + assert mock_client.send_voice_assistant_audio.call_args_list[i].args == ( + b"\x00" * 1024, + ) + assert mock_client.send_voice_assistant_audio.call_args_list[15].args == ( + b"\x00" * 640, + ) + + +async def test_stream_tts_audio_cancel_between_chunks( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test cancel/abort when self._is_running becomes False (between chunks).""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + header_chunk = _make_wav_header(data_chunk_size=8) + + async def async_stream_cancel(): + yield header_chunk + satellite._is_running = False + yield b"\x00" * 8 + + stream = _ChunkedMockResultStream(hass, "wav", []) + stream.async_stream_result = async_stream_cancel + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_not_called() + + +async def test_stream_tts_audio_cancel_inner_loop( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test cancel/abort when self._is_running becomes False (inner chunk loop).""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + audio_data = b"\x00" * 2048 + full_wav = _make_wav_header(data_chunk_size=len(audio_data)) + audio_data + + original_send = satellite._send_tts_audio + + def send_then_stop(payload: bytes) -> None: + original_send(payload) + satellite._is_running = False + + stream = _ChunkedMockResultStream(hass, "wav", [full_wav]) + with patch.object(satellite, "_send_tts_audio", side_effect=send_then_stop): + await satellite._stream_tts_audio(stream, samples_per_chunk=512) + assert mock_client.send_voice_assistant_audio.call_count == 1 + + +async def test_stream_tts_audio_odd_junk_padding( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test odd-sized JUNK chunk with RIFF word-alignment padding.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + odd_junk_chunk = ( + struct.pack("<4sI", b"JUNK", 5) + b"junk!" + b"\x00" + ) # 5 bytes + 1 pad + header_with_odd_junk = ( + struct.pack("<4sI4s", b"RIFF", 200, b"WAVE") + + odd_junk_chunk + + struct.pack("<4sI", b"fmt ", 16) + + struct.pack(" None: + """Test trailing metadata after data chunk is not forwarded as audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + audio_payload = b"\x11\x22" * 4 # 8 bytes of audio + trailing_list = struct.pack("<4sI", b"LIST", 4) + b"INFO" + wav_with_trailing = ( + _make_wav_header(data_chunk_size=len(audio_payload)) + + audio_payload + + trailing_list + ) + stream = _ChunkedMockResultStream(hass, "wav", [wav_with_trailing]) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_called_once_with(audio_payload) diff --git a/tests/components/esphome/test_wav_parser.py b/tests/components/esphome/test_wav_parser.py new file mode 100644 index 000000000000..c53ccaf35884 --- /dev/null +++ b/tests/components/esphome/test_wav_parser.py @@ -0,0 +1,270 @@ +"""Test the ESPHome WAV parser helper.""" + +from collections.abc import AsyncIterable +import io +import struct +import wave + +import pytest + +from homeassistant.components.esphome.wav_parser import stream_wav + + +def _create_wav( + channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + data: bytes = b"\x00" * 1024, +) -> bytes: + """Create a valid WAV file in bytes.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(sample_rate) + wav_file.setsampwidth(sample_width) + wav_file.setnchannels(channels) + wav_file.writeframes(data) + return wav_io.getvalue() + + +async def _async_generator(data: bytes, chunk_size: int = 128) -> AsyncIterable[bytes]: + """Yield bytes in chunks.""" + for i in range(0, len(data), chunk_size): + yield data[i : i + chunk_size] + + +async def test_stream_wav_valid() -> None: + """Test streaming a valid WAV file.""" + audio_data = b"\x01\x02\x03\x04" * 256 # 1024 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=100), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, + ): + chunks.append((chunk, is_last)) + + # samples_per_chunk = 256, expected_width = 2, expected_channels = 1 + # bytes_per_chunk = 256 * 2 * 1 = 512 bytes + # total audio data = 1024 bytes -> exactly 2 chunks of 512 bytes + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_unsupported_format() -> None: + """Test streaming with an unsupported format.""" + wav_bytes = _create_wav() + with pytest.raises(ValueError, match="Unsupported expected format"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_format="mp3", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + +async def test_stream_wav_invalid_header() -> None: + """Test streaming with an invalid WAV header.""" + invalid_wav = b"RIFFinvalidheader" + with pytest.raises( + ValueError, match="Invalid WAV format: missing RIFF/WAVE header" + ): + async for _, _ in stream_wav( + _async_generator(invalid_wav), + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + +async def test_stream_wav_missing_data_chunk() -> None: + """Test streaming a WAV that is missing data chunk.""" + # Write only a fmt chunk + header = b"RIFF" + struct.pack(" None: + """Test parameter validation against fmt chunk.""" + wav_bytes = _create_wav(channels=1, sample_width=2, sample_rate=16000) + + # Wrong channels + with pytest.raises(ValueError, match="Expected 2 channels, got 1"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=2, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + # Wrong width + with pytest.raises(ValueError, match="Expected 4 bytes per sample, got 2"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=1, + expected_width=4, + expected_sample_rate=16000, + ): + pass + + # Wrong sample rate + with pytest.raises(ValueError, match="Expected 8000 Hz, got 16000 Hz"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=1, + expected_width=2, + expected_sample_rate=8000, + ): + pass + + +async def test_stream_wav_non_pcm() -> None: + """Test non-PCM WAV format.""" + header = b"RIFF" + struct.pack(" None: + """Test streaming data chunk without fmt chunk.""" + header = b"RIFF" + struct.pack(" None: + """Test when fmt chunk size is less than 16.""" + header = b"RIFF" + struct.pack(" None: + """Test streaming a WAV file where audio data size is not a multiple of chunk size.""" + audio_data = b"\x01\x02\x03\x04" * 150 # 600 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=100), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, # 512 bytes per chunk + ): + chunks.append((chunk, is_last)) + + # First chunk: 512 bytes, not last + # Second chunk: 88 bytes, last + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_small_chunks() -> None: + """Test streaming a WAV file in very small chunks to test partial header parsing.""" + audio_data = b"\x01\x02\x03\x04" * 256 # 1024 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=5), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, + ): + chunks.append((chunk, is_last)) + + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_odd_fmt_chunk() -> None: + """Test streaming a WAV file with an odd-sized fmt chunk where the pad byte is in a separate chunk.""" + header = b"RIFF" + struct.pack(" AsyncIterable[bytes]: + yield part1 + yield part2 + + chunks = [] + async for chunk, is_last in stream_wav( + stream_generator(), + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=2, + ): + chunks.append((chunk, is_last)) + + assert len(chunks) == 1 + assert chunks[0] == (b"\x01\x02\x03\x04", True) From d0575b6b1b347e77d7379687522d51a36cb4958c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:35:01 +0200 Subject: [PATCH 388/707] Add latitude/longitude to base EntityStateAttribute enum (#176119) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/anthropic/config_flow.py | 14 +++++++++---- .../components/device_tracker/legacy.py | 7 ++++--- .../components/open_meteo/coordinator.py | 7 +++---- .../openai_conversation/config_flow.py | 14 +++++++++---- homeassistant/components/person/__init__.py | 5 +++-- homeassistant/components/person/const.py | 2 -- homeassistant/components/zone/__init__.py | 21 ++++++++++--------- homeassistant/components/zone/const.py | 2 -- homeassistant/const.py | 2 ++ homeassistant/helpers/location.py | 19 +++++++++++------ .../helpers/template/extensions/state.py | 11 +++++----- .../snapshots/test_device_tracker.ambr | 4 ++-- 12 files changed, 63 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 1cc1aabb3013..ee49b60c7874 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -9,7 +9,7 @@ import anthropic import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components.zone import ENTITY_ID_HOME, ZoneEntityStateAttribute +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntryState, @@ -18,7 +18,13 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT +from homeassistant.const import ( + CONF_API_KEY, + CONF_LLM_HASS_API, + CONF_NAME, + CONF_PROMPT, + EntityStateAttribute, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.selector import ( @@ -562,8 +568,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): { "role": "user", "content": "Where are the following coordinates located: " - f"({zone_home.attributes[ZoneEntityStateAttribute.LATITUDE]}," - f" {zone_home.attributes[ZoneEntityStateAttribute.LONGITUDE]})?", + f"({zone_home.attributes[EntityStateAttribute.LATITUDE]}," + f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?", } ], max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]), diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 0700db8e18e4..ef66d875a22c 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import util from homeassistant.components import zone -from homeassistant.components.zone import ENTITY_ID_HOME, ZoneEntityStateAttribute +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config import ( async_log_schema_error, config_per_platform, @@ -32,6 +32,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_HOME, STATE_NOT_HOME, + EntityStateAttribute, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError @@ -509,8 +510,8 @@ def async_setup_scanner_platform( zone_home = hass.states.get(ENTITY_ID_HOME) if zone_home is not None: kwargs["gps"] = [ - zone_home.attributes[ZoneEntityStateAttribute.LATITUDE], - zone_home.attributes[ZoneEntityStateAttribute.LONGITUDE], + zone_home.attributes[EntityStateAttribute.LATITUDE], + zone_home.attributes[EntityStateAttribute.LONGITUDE], ] kwargs["gps_accuracy"] = 0 diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py index 7e8066c1d748..138594cb204e 100644 --- a/homeassistant/components/open_meteo/coordinator.py +++ b/homeassistant/components/open_meteo/coordinator.py @@ -13,9 +13,8 @@ from open_meteo import ( WindSpeedUnit, ) -from homeassistant.components.zone import ZoneEntityStateAttribute from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ZONE +from homeassistant.const import CONF_ZONE, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -50,8 +49,8 @@ class OpenMeteoDataUpdateCoordinator(DataUpdateCoordinator[Forecast]): try: return await self.open_meteo.forecast( - latitude=zone.attributes[ZoneEntityStateAttribute.LATITUDE], - longitude=zone.attributes[ZoneEntityStateAttribute.LONGITUDE], + latitude=zone.attributes[EntityStateAttribute.LATITUDE], + longitude=zone.attributes[EntityStateAttribute.LONGITUDE], current_weather=True, daily=[ DailyParameters.PRECIPITATION_SUM, diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index da496037fed2..c773d3399695 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -9,7 +9,7 @@ import openai import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components.zone import ENTITY_ID_HOME, ZoneEntityStateAttribute +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, @@ -19,7 +19,13 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT +from homeassistant.const import ( + CONF_API_KEY, + CONF_LLM_HASS_API, + CONF_NAME, + CONF_PROMPT, + EntityStateAttribute, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import get_async_client @@ -647,8 +653,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): { "role": "system", "content": "Where are the following coordinates located: " - f"({zone_home.attributes[ZoneEntityStateAttribute.LATITUDE]}," - f" {zone_home.attributes[ZoneEntityStateAttribute.LONGITUDE]})?", + f"({zone_home.attributes[EntityStateAttribute.LATITUDE]}," + f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?", } ], text={ diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 17153dc3e0b1..fc67ed9dd062 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -31,6 +31,7 @@ from homeassistant.const import ( # noqa: F401 STATE_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -601,9 +602,9 @@ class Person( } if self._latitude is not None: - data[PersonEntityStateAttribute.LATITUDE] = self._latitude + data[EntityStateAttribute.LATITUDE] = self._latitude if self._longitude is not None: - data[PersonEntityStateAttribute.LONGITUDE] = self._longitude + data[EntityStateAttribute.LONGITUDE] = self._longitude if self._gps_accuracy is not None: data[PersonEntityStateAttribute.GPS_ACCURACY] = self._gps_accuracy if self._source is not None: diff --git a/homeassistant/components/person/const.py b/homeassistant/components/person/const.py index f1b283649f2d..1945aafaeea6 100644 --- a/homeassistant/components/person/const.py +++ b/homeassistant/components/person/const.py @@ -12,8 +12,6 @@ class PersonEntityStateAttribute(StrEnum): ID = "id" DEVICE_TRACKERS = "device_trackers" IN_ZONES = "in_zones" - LATITUDE = "latitude" - LONGITUDE = "longitude" GPS_ACCURACY = "gps_accuracy" SOURCE = "source" USER_ID = "user_id" diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 07e6ff6d0c46..f53a4226fa6a 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -24,6 +24,7 @@ from homeassistant.const import ( # noqa: F401 EVENT_CORE_CONFIG_UPDATE, SERVICE_RELOAD, STATE_UNAVAILABLE, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -149,8 +150,8 @@ def async_in_zones( zone_dist := distance( latitude, longitude, - zone_attrs[ZoneEntityStateAttribute.LATITUDE], - zone_attrs[ZoneEntityStateAttribute.LONGITUDE], + zone_attrs[EntityStateAttribute.LATITUDE], + zone_attrs[EntityStateAttribute.LONGITUDE], ) ) is None @@ -208,8 +209,8 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ ): return [] input_attrs = input_zone.attributes - input_latitude: float = input_attrs[ZoneEntityStateAttribute.LATITUDE] - input_longitude: float = input_attrs[ZoneEntityStateAttribute.LONGITUDE] + input_latitude: float = input_attrs[EntityStateAttribute.LATITUDE] + input_longitude: float = input_attrs[EntityStateAttribute.LONGITUDE] input_radius: float = input_attrs[ZoneEntityStateAttribute.RADIUS] zones: list[tuple[str, float, float]] = [] @@ -231,8 +232,8 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ zone_dist := distance( input_latitude, input_longitude, - zone_attrs[ZoneEntityStateAttribute.LATITUDE], - zone_attrs[ZoneEntityStateAttribute.LONGITUDE], + zone_attrs[EntityStateAttribute.LATITUDE], + zone_attrs[EntityStateAttribute.LONGITUDE], ) ) is None: continue @@ -291,8 +292,8 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - zone_dist = distance( latitude, longitude, - zone.attributes[ZoneEntityStateAttribute.LATITUDE], - zone.attributes[ZoneEntityStateAttribute.LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], ) if zone_dist is None or zone.attributes[ZoneEntityStateAttribute.RADIUS] is None: @@ -520,8 +521,8 @@ class Zone(collection.CollectionEntity): def _generate_attrs(self) -> None: """Generate new attrs based on config.""" self._attr_extra_state_attributes = { - ZoneEntityStateAttribute.LATITUDE: self._config[CONF_LATITUDE], - ZoneEntityStateAttribute.LONGITUDE: self._config[CONF_LONGITUDE], + EntityStateAttribute.LATITUDE: self._config[CONF_LATITUDE], + EntityStateAttribute.LONGITUDE: self._config[CONF_LONGITUDE], ZoneEntityStateAttribute.RADIUS: self._config[CONF_RADIUS], ZoneEntityStateAttribute.PASSIVE: self._config[CONF_PASSIVE], ZoneEntityStateAttribute.PERSONS: sorted(self._persons_in_zone), diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index 9e9215d6f23a..0725b2ce6fdf 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -10,8 +10,6 @@ HOME_ZONE = "home" class ZoneEntityStateAttribute(StrEnum): """State attributes for zone entities.""" - LATITUDE = "latitude" - LONGITUDE = "longitude" RADIUS = "radius" PASSIVE = "passive" PERSONS = "persons" diff --git a/homeassistant/const.py b/homeassistant/const.py index 587f83d70f33..346f9a37f58b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -474,6 +474,8 @@ class EntityStateAttribute(StrEnum): ENTITY_PICTURE = "entity_picture" FRIENDLY_NAME = "friendly_name" ICON = "icon" + LATITUDE = "latitude" + LONGITUDE = "longitude" RESTORED = "restored" SUPPORTED_FEATURES = "supported_features" UNIT_OF_MEASUREMENT = "unit_of_measurement" diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index 42c251e72d4f..772cb2d57290 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -3,7 +3,7 @@ from collections.abc import Iterable import logging -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, State from homeassistant.util import location as location_util @@ -17,8 +17,12 @@ def has_location(state: State) -> bool: """ return ( isinstance(state, State) - and isinstance(state.attributes.get(ATTR_LATITUDE), (float, int)) - and isinstance(state.attributes.get(ATTR_LONGITUDE), (float, int)) + and isinstance( + state.attributes.get(EntityStateAttribute.LATITUDE), (float, int) + ) + and isinstance( + state.attributes.get(EntityStateAttribute.LONGITUDE), (float, int) + ) ) @@ -36,8 +40,8 @@ def closest(latitude: float, longitude: float, states: Iterable[State]) -> State with_location, key=lambda state: ( location_util.distance( - state.attributes.get(ATTR_LATITUDE), - state.attributes.get(ATTR_LONGITUDE), + state.attributes.get(EntityStateAttribute.LATITUDE), + state.attributes.get(EntityStateAttribute.LONGITUDE), latitude, longitude, ) @@ -124,4 +128,7 @@ def resolve_zone(hass: HomeAssistant, zone_name: str) -> str | None: def _get_location_from_attributes(entity_state: State) -> str: """Get the lat/long string from an entities attributes.""" attr = entity_state.attributes - return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}" + return ( + f"{attr.get(EntityStateAttribute.LATITUDE)}," + f"{attr.get(EntityStateAttribute.LONGITUDE)}" + ) diff --git a/homeassistant/helpers/template/extensions/state.py b/homeassistant/helpers/template/extensions/state.py index 4133789782c3..bb851959a9ca 100644 --- a/homeassistant/helpers/template/extensions/state.py +++ b/homeassistant/helpers/template/extensions/state.py @@ -6,11 +6,10 @@ from typing import TYPE_CHECKING, Any from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_PERSONS, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfLength, ) from homeassistant.core import State, valid_entity_id @@ -228,8 +227,8 @@ class StateExtension(BaseTemplateExtension): ) return None - latitude = point_state.attributes[ATTR_LATITUDE] - longitude = point_state.attributes[ATTR_LONGITUDE] + latitude = point_state.attributes[EntityStateAttribute.LATITUDE] + longitude = point_state.attributes[EntityStateAttribute.LONGITUDE] entities = args[1] @@ -308,8 +307,8 @@ class StateExtension(BaseTemplateExtension): ) return None - latitude = point_state.attributes[ATTR_LATITUDE] - longitude = point_state.attributes[ATTR_LONGITUDE] + latitude = point_state.attributes[EntityStateAttribute.LATITUDE] + longitude = point_state.attributes[EntityStateAttribute.LONGITUDE] locations.append((latitude, longitude)) diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index 6f11fea1a50b..65bcc16db700 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -41,8 +41,8 @@ : True, : 'test home', : 'mdi:home', - : 32.87336, - : -117.22743, + : 32.87336, + : -117.22743, : False, : list([ ]), From f8fc89f9d37d99bee38d1830c5d11f813d25acef Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:37:27 +0800 Subject: [PATCH 389/707] Remove unused hass.data[DOMAIN] from discord (#176118) --- homeassistant/components/discord/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py index 8c1e80527f8c..dd577fb3c72c 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -37,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: finally: await discord_bot.close() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data - hass.async_create_task( discovery.async_load_platform( hass, Platform.NOTIFY, DOMAIN, dict(entry.data), hass.data[DATA_HASS_CONFIG] From 94ef0af696f2d6e050d075dd86ead701a86f59cc Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:38:23 +0800 Subject: [PATCH 390/707] Remove unused hass.data[DOMAIN] from lg_soundbar (#176117) --- homeassistant/components/lg_soundbar/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/lg_soundbar/__init__.py b/homeassistant/components/lg_soundbar/__init__.py index f32473c52811..6df848cfa15b 100644 --- a/homeassistant/components/lg_soundbar/__init__.py +++ b/homeassistant/components/lg_soundbar/__init__.py @@ -7,7 +7,6 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.exceptions import ConfigEntryNotReady from .config_flow import test_connect -from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -18,7 +17,6 @@ async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Set up platform from a ConfigEntry.""" - hass.data.setdefault(DOMAIN, {}) # Verify the device is reachable with the given # config before setting up the platform try: From d578a07cd7c016198d8766db8d0094c0416609a7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 18:11:48 +0200 Subject: [PATCH 391/707] Deprecate async_render_no_api_prompt (#176111) Co-authored-by: Claude --- homeassistant/helpers/llm.py | 2 ++ tests/helpers/test_llm.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 49ef6fe81b92..86b5726a1c53 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -45,6 +45,7 @@ from . import ( selector, service, ) +from .deprecation import deprecated_function from .singleton import singleton ACTION_PARAMETERS_CACHE: HassKey[ @@ -72,6 +73,7 @@ NO_ENTITIES_PROMPT = ( ) +@deprecated_function("an empty string", breaks_in_ha_version="2027.2") @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: """Return the prompt to be used when no API is configured. diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index a7606984d004..59c1c5dcef54 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -2021,3 +2021,14 @@ async def test_get_exposed_entities_timestamp_conversion(hass: HomeAssistant) -> hass, "conversation", include_state=False ) assert "state" not in exposed_no_state["entities"]["sensor.test_timestamp"] + + +async def test_deprecated_async_render_no_api_prompt( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the deprecated async_render_no_api_prompt helper.""" + assert llm.async_render_no_api_prompt(hass) == "" + assert ( + "The deprecated function async_render_no_api_prompt was called. It will be " + "removed in HA Core 2027.2. Use an empty string instead" + ) in caplog.text From 1ba9b9c1a1aa8738e22cc5dc0ba8e50104b11c6b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:15:48 +0200 Subject: [PATCH 392/707] Migrate InputText entity to inherit TextEntity (#175770) --- .../components/input_text/__init__.py | 91 ++++++------------- tests/components/input_text/test_init.py | 4 +- 2 files changed, 30 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 365e489a79a4..061f5f2af85a 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -5,7 +5,8 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.components.text import TextEntity +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_MODE, CONF_ICON, @@ -187,21 +188,30 @@ class InputTextStorageCollection(collection.DictStorageCollection): return {CONF_ID: item[CONF_ID]} | update_data -class InputText(collection.CollectionEntity, RestoreEntity): +# pylint: disable-next=home-assistant-enforce-class-module +class InputText(collection.CollectionEntity, TextEntity, RestoreEntity): """Represent a text box.""" - _unrecorded_attributes = frozenset( - {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} - ) + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) _attr_should_poll = False - _current_value: str | None editable: bool def __init__(self, config: ConfigType) -> None: """Initialize a text input.""" - self._config = config - self._current_value = config.get(CONF_INITIAL) + self._attr_native_value = config.get(CONF_INITIAL) + self._update_config_attributes(config) + + def _update_config_attributes(self, config: ConfigType) -> None: + """Update attributes based on the config.""" + self._attr_icon = config.get(CONF_ICON) + self._attr_mode = config[CONF_MODE] + self._attr_name = config.get(CONF_NAME) + self._attr_native_min = config[CONF_MIN] + self._attr_native_max = config[CONF_MAX] + self._attr_pattern = config.get(CONF_PATTERN) + self._attr_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_unique_id = config[CONF_ID] @classmethod @override @@ -220,87 +230,42 @@ class InputText(collection.CollectionEntity, RestoreEntity): input_text.editable = False return input_text - @property - @override - def name(self) -> str | None: - """Return the name of the text input entity.""" - return self._config.get(CONF_NAME) - - @property - @override - def icon(self) -> str | None: - """Return the icon to be used for this entity.""" - return self._config.get(CONF_ICON) - - @property - def _maximum(self) -> int: - """Return max len of the text.""" - return self._config[CONF_MAX] # type: ignore[no-any-return] - - @property - def _minimum(self) -> int: - """Return min len of the text.""" - return self._config[CONF_MIN] # type: ignore[no-any-return] - - @property - @override - def state(self) -> str | None: - """Return the state of the component.""" - return self._current_value - - @property - @override - def unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._config.get(CONF_UNIT_OF_MEASUREMENT) - - @property - @override - def unique_id(self) -> str: - """Return unique id for the entity.""" - return self._config[CONF_ID] # type: ignore[no-any-return] - @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return { - ATTR_EDITABLE: self.editable, - ATTR_MIN: self._minimum, - ATTR_MAX: self._maximum, - ATTR_PATTERN: self._config.get(CONF_PATTERN), - ATTR_MODE: self._config[CONF_MODE], - } + return {ATTR_EDITABLE: self.editable} @override async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() - if self._current_value is not None: + if self._attr_native_value is not None: return state = await self.async_get_last_state() value = state.state if state else None # Check against None because value can be 0 - if value is not None and self._minimum <= len(value) <= self._maximum: - self._current_value = value + if value is not None and self.native_min <= len(value) <= self.native_max: + self._attr_native_value = value + @override async def async_set_value(self, value: str) -> None: """Select new value.""" - if len(value) < self._minimum or len(value) > self._maximum: + if len(value) < self.native_min or len(value) > self.native_max: _LOGGER.warning( "Invalid value: %s (length range %s - %s)", value, - self._minimum, - self._maximum, + self.native_min, + self.native_max, ) return - self._current_value = value + self._attr_native_value = value self.async_write_ha_state() @override async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" - self._config = config + self._update_config_attributes(config) self.async_write_ha_state() diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 393e275aa92e..ada8cd1f7936 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -297,7 +297,7 @@ async def test_reload( autospec=True, return_value={ DOMAIN: { - "test_2": {"initial": "test reloaded", ATTR_MIN: 12}, + "test_2": {"initial": "test reloaded", ATTR_MIN: 6}, "test_3": {"initial": "test 3", ATTR_MAX: 21}, } }, @@ -326,7 +326,7 @@ async def test_reload( assert state_1 is None assert state_2 is not None assert state_3 is not None - assert state_2.attributes[ATTR_MIN] == 12 + assert state_2.attributes[ATTR_MIN] == 6 assert state_3.attributes[ATTR_MAX] == 21 From cb7ba5008fd5cdea652d8d7bfc82b2c1651aa056 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:45:18 +0200 Subject: [PATCH 393/707] Use state attribute enums in Template (#175968) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/template/entity.py | 10 ++++------ homeassistant/components/template/update.py | 11 +++++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 755a928af372..edcbc7056820 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -6,15 +6,13 @@ from dataclasses import dataclass from typing import Any, override from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, CONF_DEVICE_ID, CONF_ICON, CONF_NAME, CONF_OPTIMISTIC, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr @@ -289,9 +287,9 @@ class AbstractTemplateEntity(Entity): """Restore attributes from the last state.""" # Restore built-in attributes from templates for conf_key, attr, _attr in ( - (CONF_ICON, ATTR_ICON, "_attr_icon"), - (CONF_NAME, ATTR_FRIENDLY_NAME, "_attr_name"), - (CONF_PICTURE, ATTR_ENTITY_PICTURE, "_attr_entity_picture"), + (CONF_ICON, EntityStateAttribute.ICON, "_attr_icon"), + (CONF_NAME, EntityStateAttribute.FRIENDLY_NAME, "_attr_name"), + (CONF_PICTURE, EntityStateAttribute.ENTITY_PICTURE, "_attr_entity_picture"), ): if conf_key not in self._config or attr not in last_state.attributes: continue diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py index c0c33b5a0406..dcd7ff22284e 100644 --- a/homeassistant/components/template/update.py +++ b/homeassistant/components/template/update.py @@ -6,13 +6,12 @@ from typing import TYPE_CHECKING, Any, override import voluptuous as vol from homeassistant.components.update import ( - ATTR_INSTALLED_VERSION, - ATTR_LATEST_VERSION, DEVICE_CLASSES_SCHEMA, DOMAIN as UPDATE_DOMAIN, ENTITY_ID_FORMAT, UpdateEntity, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME @@ -244,8 +243,12 @@ class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity): @override def restore_last_state_state(self, last_state: State) -> bool: """Restore the state from the last state.""" - self._attr_installed_version = last_state.attributes[ATTR_INSTALLED_VERSION] - self._attr_latest_version = last_state.attributes[ATTR_LATEST_VERSION] + self._attr_installed_version = last_state.attributes[ + UpdateEntityStateAttribute.INSTALLED_VERSION + ] + self._attr_latest_version = last_state.attributes[ + UpdateEntityStateAttribute.LATEST_VERSION + ] return True From e09a062dfa6d2162f74c86bcce5b757e98fc9c3e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 18:45:37 +0200 Subject: [PATCH 394/707] Remove unused states_entity_filter from recorder Filters (#176116) Co-authored-by: Claude --- homeassistant/components/recorder/filters.py | 15 +-------------- tests/components/recorder/test_filters.py | 8 -------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index d44d1d836fee..33a0df382171 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType -from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States, StatesMeta +from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, StatesMeta DOMAIN = "history" HISTORY_FILTERS = "history_filters" @@ -205,19 +205,6 @@ class Filters: # - Otherwise: exclude return i_entities - def states_entity_filter(self) -> ColumnElement: - """Generate the States.entity_id filter query. - - This is no longer used except by the legacy queries. - """ - - def _encoder(data: Any) -> Any: - """Nothing to encode for states since there is no json.""" - return data - - # The type annotation should be improved so the type ignore can be removed - return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] - def states_metadata_entity_filter(self) -> ColumnElement: """Generate the StatesMeta.entity_id filter query.""" diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py index 2ba7bfbcd0b4..82cf2e786b82 100644 --- a/tests/components/recorder/test_filters.py +++ b/tests/components/recorder/test_filters.py @@ -150,13 +150,5 @@ async def test_an_empty_filter_raises() -> None: "No filter configuration provided, check" " has_config before calling this method" ), - ): - filters.states_entity_filter() - with pytest.raises( - RuntimeError, - match=( - "No filter configuration provided, check" - " has_config before calling this method" - ), ): filters.events_entity_filter() From ad7c885f86e4d83d1b4d890bc822a804063e26cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:46:13 +0200 Subject: [PATCH 395/707] Use entity state attribute enums in proximity (#175984) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/proximity/coordinator.py | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index cf9e75ea8c2c..bd409a0889a6 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -5,17 +5,26 @@ from dataclasses import dataclass import logging from typing import cast, override -from homeassistant.components.device_tracker import ATTR_IN_ZONES -from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN, ENTITY_ID_HOME +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, +) +from homeassistant.components.person import ( + DOMAIN as PERSON_DOMAIN, + PersonEntityStateAttribute, +) +from homeassistant.components.zone import ( + DOMAIN as ZONE_DOMAIN, + ENTITY_ID_HOME, + ZoneEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, STATE_HOME, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -48,6 +57,19 @@ _LOGGER = logging.getLogger(__name__) type ProximityConfigEntry = ConfigEntry[ProximityDataUpdateCoordinator] +def _tracked_in_zones(state: State) -> list[str] | None: + """Return the zone membership of a tracked entity state. + + Only person and device_tracker entities report zone membership; each + exposes it under its own platform enum. Any other domain returns None. + """ + if state.domain == PERSON_DOMAIN: + return state.attributes.get(PersonEntityStateAttribute.IN_ZONES) + if state.domain == DEVICE_TRACKER_DOMAIN: + return state.attributes.get(DeviceTrackerEntityStateAttribute.IN_ZONES) + return None + + @dataclass class StateChangedData: """StateChangedData class.""" @@ -166,12 +188,12 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): # authoritative for every tracked entity and this method should reduce to the # membership check alone; the fallback must be removed, as second-guessing an # empty list would then be incorrect. - if in_zones := tracked_entity_state.attributes.get(ATTR_IN_ZONES): + if in_zones := _tracked_in_zones(tracked_entity_state): return zone.entity_id in in_zones # Remove once legacy device trackers (2027.5) and location_name (2027.7) # are gone, see detailed comment above - zone_friendly_name = zone.attributes.get(ATTR_FRIENDLY_NAME) + zone_friendly_name = zone.attributes.get(EntityStateAttribute.FRIENDLY_NAME) return ( zone_friendly_name is not None and tracked_entity_state.state.lower() == zone_friendly_name.lower() @@ -204,8 +226,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): return None distance_to_centre = distance( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], latitude, longitude, ) @@ -214,7 +236,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): # since zones must have lat/lon coordinates assert distance_to_centre is not None - zone_radius: float = zone.attributes["radius"] + zone_radius: float = zone.attributes[ZoneEntityStateAttribute.RADIUS] if zone_radius > distance_to_centre: # we've arrived the zone return 0 @@ -246,14 +268,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): return None old_distance = distance( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], old_latitude, old_longitude, ) new_distance = distance( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], new_latitude, new_longitude, ) @@ -309,8 +331,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): entities_data[entity_id][ATTR_DIST_TO] = self._calc_distance_to_zone( zone_state, tracked_entity_state, - tracked_entity_state.attributes.get(ATTR_LATITUDE), - tracked_entity_state.attributes.get(ATTR_LONGITUDE), + tracked_entity_state.attributes.get(EntityStateAttribute.LATITUDE), + tracked_entity_state.attributes.get(EntityStateAttribute.LONGITUDE), ) if entities_data[entity_id][ATTR_DIST_TO] is None: _LOGGER.debug( @@ -331,8 +353,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) if (old_state := state_change_data.old_state) is not None: - old_lat = old_state.attributes.get(ATTR_LATITUDE) - old_lon = old_state.attributes.get(ATTR_LONGITUDE) + old_lat = old_state.attributes.get(EntityStateAttribute.LATITUDE) + old_lon = old_state.attributes.get(EntityStateAttribute.LONGITUDE) else: old_lat = None old_lon = None @@ -343,8 +365,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): new_state, old_lat, old_lon, - new_state.attributes.get(ATTR_LATITUDE), - new_state.attributes.get(ATTR_LONGITUDE), + new_state.attributes.get(EntityStateAttribute.LATITUDE), + new_state.attributes.get(EntityStateAttribute.LONGITUDE), ) ) From 2374291a8ae38cb7759867bac2fc45f892e9f5ef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:46:37 +0200 Subject: [PATCH 396/707] Use EntityStateAttribute enum in iCloud (#175977) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/icloud/account.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index d98ec46d1daf..8c04d071d402 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -16,7 +16,7 @@ from pyicloud.services.findmyiphone import AppleDevice from homeassistant.components.zone import async_active_zone from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EntityStateAttribute from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send @@ -256,8 +256,8 @@ class IcloudAccount: for zone_state in zones: if zone_state is None: continue - zone_state_lat = zone_state.attributes[DEVICE_LOCATION_LATITUDE] - zone_state_long = zone_state.attributes[DEVICE_LOCATION_LONGITUDE] + zone_state_lat = zone_state.attributes[EntityStateAttribute.LATITUDE] + zone_state_long = zone_state.attributes[EntityStateAttribute.LONGITUDE] zone_distance = distance( device.location[DEVICE_LOCATION_LATITUDE], device.location[DEVICE_LOCATION_LONGITUDE], From ab91e3202db9e7276fb26b3492c8b80ce577cfad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:47:32 +0200 Subject: [PATCH 397/707] Migrate InputNumber entity to inherit NumberEntity (#175772) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/input_number/__init__.py | 117 +++++++----------- tests/components/input_number/test_init.py | 23 ++++ 2 files changed, 67 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 92bd543122e6..7e9b2c9e054a 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -6,7 +6,8 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.components.number import NumberEntity +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_MODE, CONF_ICON, @@ -149,7 +150,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_VALUE, {vol.Required(ATTR_VALUE): vol.Coerce(float)}, - "async_set_value", + "async_set_native_value", ) component.async_register_entity_service(SERVICE_INCREMENT, None, "async_increment") @@ -199,20 +200,31 @@ class NumberStorageCollection(collection.DictStorageCollection): return {CONF_ID: item[CONF_ID]} | update_data -class InputNumber(collection.CollectionEntity, RestoreEntity): +# pylint: disable-next=home-assistant-enforce-class-module +class InputNumber(collection.CollectionEntity, NumberEntity, RestoreEntity): """Representation of a slider.""" - _unrecorded_attributes = frozenset( - {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP} - ) + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) _attr_should_poll = False editable: bool def __init__(self, config: ConfigType) -> None: """Initialize an input number.""" - self._config = config - self._current_value: float | None = config.get(CONF_INITIAL) + self._initial_value: float | None = config.get(CONF_INITIAL) + self._attr_native_value = self._initial_value + self._update_config_attributes(config) + + def _update_config_attributes(self, config: ConfigType) -> None: + """Update attributes based on the config.""" + self._attr_icon = config.get(CONF_ICON) + self._attr_mode = config[CONF_MODE] + self._attr_name = config.get(CONF_NAME) + self._attr_native_min_value = config[CONF_MIN] + self._attr_native_max_value = config[CONF_MAX] + self._attr_native_step = config[CONF_STEP] + self._attr_unique_id = config[CONF_ID] + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @classmethod @override @@ -231,69 +243,20 @@ class InputNumber(collection.CollectionEntity, RestoreEntity): input_num.editable = False return input_num - @property - def _minimum(self) -> float: - """Return minimum allowed value.""" - return self._config[CONF_MIN] - - @property - def _maximum(self) -> float: - """Return maximum allowed value.""" - return self._config[CONF_MAX] - - @property - @override - def name(self): - """Return the name of the input slider.""" - return self._config.get(CONF_NAME) - - @property - @override - def icon(self) -> str | None: - """Return the icon to be used for this entity.""" - return self._config.get(CONF_ICON) - - @property - @override - def state(self): - """Return the state of the component.""" - return self._current_value - - @property - def _step(self) -> int: - """Return entity's increment/decrement step.""" - return self._config[CONF_STEP] - - @property - @override - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._config.get(CONF_UNIT_OF_MEASUREMENT) - - @property - @override - def unique_id(self) -> str | None: - """Return unique id of the entity.""" - return self._config[CONF_ID] - @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { - ATTR_INITIAL: self._config.get(CONF_INITIAL), + ATTR_INITIAL: self._initial_value, ATTR_EDITABLE: self.editable, - ATTR_MIN: self._minimum, - ATTR_MAX: self._maximum, - ATTR_STEP: self._step, - ATTR_MODE: self._config[CONF_MODE], } @override async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - if self._current_value is not None: + if self._attr_native_value is not None: return value: float | None = None @@ -302,39 +265,47 @@ class InputNumber(collection.CollectionEntity, RestoreEntity): value = float(state.state) # Check against None because value can be 0 - if value is not None and self._minimum <= value <= self._maximum: - self._current_value = value + if ( + value is not None + and self.native_min_value <= value <= self.native_max_value + ): + self._attr_native_value = value else: - self._current_value = self._minimum + self._attr_native_value = self.native_min_value - async def async_set_value(self, value): + @override + async def async_set_native_value(self, value): """Set new value.""" num_value = float(value) - if num_value < self._minimum or num_value > self._maximum: + if num_value < self.native_min_value or num_value > self.native_max_value: raise vol.Invalid( - f"Invalid value for {self.entity_id}: {value} (range {self._minimum} -" - f" {self._maximum})" + f"Invalid value for {self.entity_id}: {value} (range " + f"{self.native_min_value} - {self.native_max_value})" ) - self._current_value = num_value + self._attr_native_value = num_value self.async_write_ha_state() async def async_increment(self): """Increment value.""" - await self.async_set_value(min(self._current_value + self._step, self._maximum)) + await self.async_set_native_value( + min(self._attr_native_value + self.native_step, self.native_max_value) + ) async def async_decrement(self): """Decrement value.""" - await self.async_set_value(max(self._current_value - self._step, self._minimum)) + await self.async_set_native_value( + max(self._attr_native_value - self.native_step, self.native_min_value) + ) @override async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" - self._config = config + self._update_config_attributes(config) # just in case min/max values changed - if self._current_value is None: + if self._attr_native_value is None: return - self._current_value = min(self._current_value, self._maximum) - self._current_value = max(self._current_value, self._minimum) + self._attr_native_value = min(self._attr_native_value, self.native_max_value) + self._attr_native_value = max(self._attr_native_value, self.native_min_value) self.async_write_ha_state() diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 6b94a68035b8..f5cf05e797ad 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, ) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.exceptions import Unauthorized @@ -237,6 +238,28 @@ async def test_mode(hass: HomeAssistant) -> None: assert state.attributes["mode"] == "slider" +async def test_unit_of_measurement(hass: HomeAssistant) -> None: + """Test unit of measurement is exposed in the state attributes.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "with_unit": {"min": 0, "max": 100, "unit_of_measurement": "°C"}, + "without_unit": {"min": 0, "max": 100}, + } + }, + ) + + state = hass.states.get("input_number.with_unit") + assert state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + + state = hass.states.get("input_number.without_unit") + assert state + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + async def test_restore_state(hass: HomeAssistant) -> None: """Ensure states are restored on startup.""" mock_restore_cache( From 464dd8f2b8b93947b98555bb2ba1644933d79ff5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 18:48:01 +0200 Subject: [PATCH 398/707] Guard MutexPool.dispose with pool_lock to avoid recorder shutdown segfault (#176113) Co-authored-by: Claude --- homeassistant/components/recorder/pool.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index d3ce05fae688..132a77533e2e 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -159,6 +159,24 @@ class MutexPool(StaticPool): ) MutexPool.pool_lock.release() + @override + def dispose(self) -> None: + """Dispose of the shared connection under the pool lock. + + StaticPool.dispose() closes the single in-memory connection directly. + Without the lock it can close it while another thread has it checked + out and is mid-query, freeing the sqlite3 handle underneath a running + statement -> segfault. Holding pool_lock makes dispose wait for the + in-flight checkout to return first. + """ + # pylint: disable-next=consider-using-with + got_lock = MutexPool.pool_lock.acquire(timeout=10) + try: + super().dispose() + finally: + if got_lock: + MutexPool.pool_lock.release() + @override def _do_get(self) -> ConnectionPoolEntry: if DEBUG_MUTEX_POOL_TRACE: From 59232e305d2f2c36ab08a5df8eb2e04747061d5f Mon Sep 17 00:00:00 2001 From: Sarabveer Singh <4297171+sarabveer@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:48:42 -0400 Subject: [PATCH 399/707] Remove Tesla Wall Connector scan interval option (#176020) --- .../components/tesla_wall_connector/__init__.py | 8 -------- .../components/tesla_wall_connector/coordinator.py | 10 +--------- tests/components/tesla_wall_connector/conftest.py | 3 +-- 3 files changed, 2 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 480441bf46b8..986c9b600af0 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -12,7 +12,6 @@ from .coordinator import ( WallConnectorConfigEntry, WallConnectorCoordinator, WallConnectorData, - get_poll_interval, ) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -45,16 +44,9 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True -async def update_listener(hass: HomeAssistant, entry: WallConnectorConfigEntry) -> None: - """Handle options update.""" - entry.runtime_data.update_coordinator.update_interval = get_poll_interval(entry) - - async def async_unload_entry( hass: HomeAssistant, entry: WallConnectorConfigEntry ) -> bool: diff --git a/homeassistant/components/tesla_wall_connector/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py index 61e1416f5f6a..dfc22918abda 100644 --- a/homeassistant/components/tesla_wall_connector/coordinator.py +++ b/homeassistant/components/tesla_wall_connector/coordinator.py @@ -13,7 +13,6 @@ from tesla_wall_connector.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -40,13 +39,6 @@ class WallConnectorData: serial_number: str -def get_poll_interval(entry: ConfigEntry) -> timedelta: - """Get the poll interval from config.""" - return timedelta( - seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - - class WallConnectorCoordinator(DataUpdateCoordinator[dict]): """Class to manage fetching Tesla Wall Connector data.""" @@ -65,7 +57,7 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]): _LOGGER, config_entry=entry, name="tesla-wallconnector", - update_interval=get_poll_interval(entry), + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self._hostname = hostname self._wall_connector = wall_connector diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index b69ebfef6a92..8d72d14d9720 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -12,7 +12,7 @@ from homeassistant.components.tesla_wall_connector.const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, ) -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -58,7 +58,6 @@ async def create_wall_connector_entry( entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, - options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) entry.add_to_hass(hass) From 750850f0bafc34291ede4e74abde1c025eb03418 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 9 Jul 2026 18:48:59 +0200 Subject: [PATCH 400/707] Fix ZHA device trigger cache not resolving quirks (#175895) --- homeassistant/components/zha/__init__.py | 9 ++++-- homeassistant/components/zha/radio_manager.py | 20 ++++++++++--- tests/components/zha/test_device_trigger.py | 30 +++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 388d22664ea2..952496bdce62 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -9,6 +9,7 @@ from yarl import URL from zha.application.const import BAUD_RATES, RadioType from zha.application.gateway import Gateway from zha.application.helpers import ZHAData +from zha.quirks import DEVICE_REGISTRY from zha.zigbee.device import get_device_automation_triggers from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError @@ -158,11 +159,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_gateway = await Gateway.async_from_config(zha_lib_data) - # Load and cache device trigger information early + # Load and cache device trigger information early. Quirks were registered by + # `Gateway.async_from_config` above, so pass the resolver to quirk devices + # and surface quirk-defined triggers (e.g. remote button presses). device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - async with radio_mgr.create_zigpy_app(connect=False) as app: + async with radio_mgr.create_zigpy_app( + connect=False, device_resolver=DEVICE_REGISTRY.resolve + ) as app: for dev in app.devices.values(): dev_entry = device_registry.async_get_device( identifiers={(DOMAIN, str(dev.ieee))}, diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index cdae1558e8e1..6ef2e4013880 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -1,7 +1,7 @@ """ZHA radio manager.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable import contextlib from contextlib import suppress import copy @@ -22,6 +22,7 @@ from zigpy.config import ( CONF_NWK_BACKUP_ENABLED, SCHEMA_DEVICE, ) +import zigpy.device from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries @@ -174,9 +175,17 @@ class ZhaRadioManager: @contextlib.asynccontextmanager async def create_zigpy_app( - self, *, connect: bool = True + self, + *, + connect: bool = True, + device_resolver: Callable[[zigpy.device.Device], zigpy.device.Device] + | None = None, ) -> AsyncGenerator[ControllerApplication]: - """Connect to the radio with the current config and then clean up.""" + """Connect to the radio with the current config and then clean up. + + `device_resolver` is forwarded to zigpy so devices loaded from the + database are quirk-resolved to get quirk-defined device triggers. + """ assert self.radio_type is not None config = get_zha_data(self.hass).yaml_config @@ -201,7 +210,10 @@ class ZhaRadioManager: app_config[CONF_USE_THREAD] = False app = await self.radio_type.controller.new( - app_config, auto_form=False, start_radio=False + app_config, + auto_form=False, + start_radio=False, + device_resolver=device_resolver, ) try: diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 0839bbfdcb9a..6afe6d063931 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -1,10 +1,11 @@ """ZHA device automation trigger tests.""" from collections.abc import Callable, Coroutine -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest -from zha.application.const import ATTR_ENDPOINT_ID +from zha.application.const import ATTR_ENDPOINT_ID, RadioType +from zha.quirks import DEVICE_REGISTRY from zigpy.application import ControllerApplication from zigpy.device import Device as ZigpyDevice import zigpy.profiles.zha @@ -558,3 +559,28 @@ async def test_validate_trigger_config_unloaded_bad_info( ) assert "Unable to find trigger" in caplog.text + + +async def test_device_trigger_cache_built_with_quirk_resolver( + zigpy_app_controller: ControllerApplication, + setup_zha: Callable[..., Coroutine[None]], +) -> None: + """Test the early device trigger cache is built with quirk resolution. + + Regression test: without quirk resolution, quirk-defined triggers (e.g. + remote button presses) are missing whenever the cache is used as a + fallback (i.e. before ZHA has finished loading). + """ + with patch.object( + RadioType.ezsp.controller, + "new", + AsyncMock(return_value=zigpy_app_controller), + ) as mock_new: + await setup_zha() + + # Both the trigger cache app and the gateway app must quirk-resolve devices + assert len(mock_new.await_args_list) == 2 + assert all( + call.kwargs.get("device_resolver") == DEVICE_REGISTRY.resolve + for call in mock_new.await_args_list + ) From 2395296739bdc0c40a7c763d498e7b9ea438ff17 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Jul 2026 19:19:00 +0200 Subject: [PATCH 401/707] Extract entities from device triggers and device actions (#175454) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- homeassistant/helpers/script.py | 9 +++++ homeassistant/helpers/trigger.py | 9 +++++ tests/components/automation/test_init.py | 1 + tests/components/script/test_init.py | 1 + tests/components/search/test_init.py | 3 +- tests/helpers/test_script.py | 13 +++++++ tests/helpers/test_trigger.py | 48 ++++++++++++++++++++++++ 7 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 39355b44b838..224d6688f033 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1829,6 +1829,15 @@ class Script: for trigger in step[CONF_WAIT_FOR_TRIGGER]: referenced |= set(trigger_helper.async_extract_entities(trigger)) + elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: + # Only extract the entity if it has been resolved to an entity + # id during validation; unvalidated configs hold an entity + # registry id. + if isinstance( + entity_id := step.get(ATTR_ENTITY_ID), str + ) and valid_entity_id(entity_id): + referenced.add(entity_id) + elif action == cv.SCRIPT_ACTION_ACTIVATE_SCENE: referenced.add(step[CONF_SCENE]) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index d34ee24d98d5..f07a0a8bf5d7 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -2067,6 +2067,15 @@ def async_extract_entities(trigger_conf: dict) -> list[str]: entity_ids.append(at_time[CONF_ENTITY_ID]) return entity_ids + if trigger_conf[CONF_PLATFORM] == "device": + # Only extract the entity if it has been resolved to an entity id + # during validation; unvalidated configs hold an entity registry id. + if isinstance( + entity_id := trigger_conf.get(CONF_ENTITY_ID), str + ) and valid_entity_id(entity_id): + return [entity_id] + return [] + if trigger_conf[CONF_PLATFORM] == "calendar": return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]] diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 938af58b0f64..8347f066fadf 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2481,6 +2481,7 @@ async def test_extraction_functions( "sensor.trigger_state", "sensor.trigger_numeric_state", "sensor.trigger_event", + "light.bla", "light.condition_state", "light.in_both", "light.in_first", diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 8f0a632fcf76..80ad3cfa2850 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -919,6 +919,7 @@ async def test_extraction_functions( "script.test3", } assert set(script.entities_in_script(hass, "script.test1")) == { + "light.device_in_both", "light.in_both", "light.in_first", } diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index b36e0611b3b8..aeb51bad6303 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -568,6 +568,7 @@ async def test_search( ItemType.AREA: {living_room_area.id}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.DEVICE: {wled_device.id}, + ItemType.ENTITY: {wled_segment_1_entity.entity_id}, ItemType.FLOOR: {first_floor.floor_id}, ItemType.INTEGRATION: {"wled"}, } @@ -711,7 +712,7 @@ async def test_search( assert not search(ItemType.ENTITY, "sensor.unknown") assert search(ItemType.ENTITY, wled_segment_1_entity.entity_id) == { ItemType.AREA: {living_room_area.id}, - ItemType.AUTOMATION: {"automation.wled_entity"}, + ItemType.AUTOMATION: {"automation.wled_entity", "automation.wled_device"}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.DEVICE: {wled_device.id}, ItemType.FLOOR: {first_floor.floor_id}, diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 4f55abf2d2c7..d33edd0dd3b7 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4833,6 +4833,18 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: }, {"action": "test.script", "data": {"without": "entity_id"}}, {"scene": "scene.hello"}, + { + "domain": "light", + "device_id": "abcdefgh", + "entity_id": "light.device_action", + "type": "turn_on", + }, + { + "domain": "light", + "device_id": "abcdefgh", + "entity_id": "1234567890abcdef1234567890abcdef", + "type": "turn_on", + }, { "choose": [ { @@ -4989,6 +5001,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.condition_list_2", "light.condition_target", "light.default_seq", + "light.device_action", "light.direct_entity_referenced", "light.entity_in_data_template", "light.entity_in_target", diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index a099ab76a555..23b77b056db5 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -6020,6 +6020,54 @@ async def test_async_extract_entities( assert trigger.async_extract_entities(trigger_conf) == expected +@pytest.mark.parametrize( + ("trigger_conf", "expected"), + [ + pytest.param( + { + "platform": "device", + "device_id": "abcdefgh", + "domain": "light", + "entity_id": "light.kitchen", + "type": "turned_on", + }, + ["light.kitchen"], + id="resolved-entity-id", + ), + pytest.param( + { + "platform": "device", + "device_id": "abcdefgh", + "domain": "light", + "entity_id": "1234567890abcdef1234567890abcdef", + "type": "turned_on", + }, + [], + id="unresolved-registry-id", + ), + pytest.param( + { + "platform": "device", + "device_id": "abcdefgh", + "domain": "sensor", + "type": "battery_level", + }, + [], + id="no-entity-id", + ), + ], +) +def test_async_extract_entities_device_trigger( + trigger_conf: dict[str, Any], expected: list[str] +) -> None: + """Test extracting entities from device trigger configs. + + Validation resolves the entity registry id to an entity id; extraction + ignores unresolved registry ids. + """ + assert trigger.async_extract_entities(trigger_conf) == expected + + _MOCK_DEVICE_ID = "_mock_device_id_" From 1608e058b52be9a3bf3d73bc527317a0095f0712 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:48:32 +0200 Subject: [PATCH 402/707] Bump uiprotect to 15.5.0 (#176129) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index f3f2c817907d..ebce3422e703 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.4.3"] + "requirements": ["uiprotect==15.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 373fa38c1502..92a6d2d7c4c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3246,7 +3246,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.4.3 +uiprotect==15.5.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From b3851c1be6d9fdc21b9211616ae5ac2c047a738d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 20:04:45 +0200 Subject: [PATCH 403/707] Add RadioFrequencyTransmitterConsumerEntity and migrate consumers (#171026) Co-authored-by: Claude Opus 4.6 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../honeywell_string_lights/entity.py | 62 +------ .../honeywell_string_lights/light.py | 18 +- .../components/novy_cooker_hood/entity.py | 62 +------ .../components/novy_cooker_hood/fan.py | 19 +- .../components/novy_cooker_hood/light.py | 7 +- .../components/radio_frequency/__init__.py | 64 +------ .../components/radio_frequency/helpers.py | 171 ++++++++++++++++++ .../components/novy_cooker_hood/test_light.py | 25 +++ 8 files changed, 230 insertions(+), 198 deletions(-) create mode 100644 homeassistant/components/radio_frequency/helpers.py diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py index 9002b8713528..983e134be29b 100644 --- a/homeassistant/components/honeywell_string_lights/entity.py +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -1,76 +1,24 @@ """Common entity for Honeywell String Lights integration.""" -import logging -from typing import override - +from homeassistant.components.radio_frequency import ( + RadioFrequencyTransmitterConsumerEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_TRANSMITTER, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -class HoneywellStringLightsEntity(Entity): +class HoneywellStringLightsEntity(RadioFrequencyTransmitterConsumerEntity): """Honeywell String Lights base entity.""" _attr_has_entity_name = True def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" - self._transmitter = entry.data[CONF_TRANSMITTER] self._attr_unique_id = entry.entry_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Honeywell", model="String Lights", ) - - @override - async def async_added_to_hass(self) -> None: - """Subscribe to transmitter entity state changes.""" - await super().async_added_to_hass() - - transmitter_entity_id = er.async_validate_entity_id( - er.async_get(self.hass), self._transmitter - ) - - @callback - def _async_transmitter_state_changed( - event: Event[EventStateChangedData], - ) -> None: - """Handle transmitter entity state changes.""" - new_state = event.data["new_state"] - transmitter_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - if transmitter_available != self.available: - _LOGGER.info( - "Transmitter %s used by %s is %s", - transmitter_entity_id, - self.entity_id, - "available" if transmitter_available else "unavailable", - ) - - self._attr_available = transmitter_available - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [transmitter_entity_id], - _async_transmitter_state_changed, - ) - ) - - # Set initial availability based on current transmitter entity state - transmitter_state = self.hass.states.get(transmitter_entity_id) - self._attr_available = ( - transmitter_state is not None - and transmitter_state.state != STATE_UNAVAILABLE - ) diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py index 0e8dee04458b..51a45cdfbcc0 100644 --- a/homeassistant/components/honeywell_string_lights/light.py +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -5,13 +5,13 @@ from typing import Any, override from rf_protocols.codes.honeywell.string_lights import CODES from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.components.radio_frequency import async_send_command from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import CONF_TRANSMITTER from .entity import HoneywellStringLightsEntity PARALLEL_UPDATES = 1 @@ -33,7 +33,11 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_name = None - _attr_should_poll = False + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + super().__init__(entry) + self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER] @override async def async_added_to_hass(self) -> None: @@ -45,20 +49,18 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - await self._async_send_command("turn_on") + await self._async_send_rf_command("turn_on") self._attr_is_on = True self.async_write_ha_state() @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - await self._async_send_command("turn_off") + await self._async_send_rf_command("turn_off") self._attr_is_on = False self.async_write_ha_state() - async def _async_send_command(self, name: str) -> None: + async def _async_send_rf_command(self, name: str) -> None: """Load the named command and send it via the configured transmitter.""" command = await CODES.async_load_command(name) - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) + await self._send_command(command) diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py index 41d86462055c..96d6001cee36 100644 --- a/homeassistant/components/novy_cooker_hood/entity.py +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -1,76 +1,24 @@ """Common entity for the Novy Cooker Hood integration.""" -import logging -from typing import override - +from homeassistant.components.radio_frequency import ( + RadioFrequencyTransmitterConsumerEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_TRANSMITTER, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -class NovyCookerHoodEntity(Entity): +class NovyCookerHoodEntity(RadioFrequencyTransmitterConsumerEntity): """Novy Cooker Hood base entity.""" _attr_assumed_state = True _attr_has_entity_name = True - _attr_should_poll = False def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" - self._transmitter = entry.data[CONF_TRANSMITTER] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Novy", model="Cooker Hood", ) - - @override - async def async_added_to_hass(self) -> None: - """Subscribe to transmitter entity state changes.""" - await super().async_added_to_hass() - - transmitter_entity_id = er.async_validate_entity_id( - er.async_get(self.hass), self._transmitter - ) - - @callback - def _async_transmitter_state_changed( - event: Event[EventStateChangedData], - ) -> None: - """Handle transmitter entity state changes.""" - new_state = event.data["new_state"] - transmitter_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - if transmitter_available != self.available: - _LOGGER.info( - "Transmitter %s used by %s is %s", - transmitter_entity_id, - self.entity_id, - "available" if transmitter_available else "unavailable", - ) - - self._attr_available = transmitter_available - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [transmitter_entity_id], - _async_transmitter_state_changed, - ) - ) - - transmitter_state = self.hass.states.get(transmitter_entity_id) - self._attr_available = ( - transmitter_state is not None - and transmitter_state.state != STATE_UNAVAILABLE - ) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index f2a8b0e6f694..08782f7d7b95 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -4,14 +4,12 @@ import math from typing import Any, override from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton -from rf_protocols.commands.novy import NovyCookerHoodCommand from homeassistant.components.fan import ( FanEntity, FanEntityFeature, FanEntityStateAttribute, ) -from homeassistant.components.radio_frequency import async_send_command from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant @@ -22,7 +20,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import SPEED_COUNT +from .const import CONF_TRANSMITTER, SPEED_COUNT from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 @@ -53,6 +51,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the fan.""" super().__init__(entry) + self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER] self._code: int = entry.data[CONF_CODE] self._level = 0 self._attr_unique_id = entry.entry_id @@ -116,7 +115,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): steps = self._steps_from_percentage(percentage_step) plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) for _ in range(steps): - await self._async_send(plus) + await self._send_command(plus) self._level = min(SPEED_COUNT, self._level + steps) self.async_write_ha_state() @@ -126,7 +125,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): steps = self._steps_from_percentage(percentage_step) minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) for _ in range(steps): - await self._async_send(minus) + await self._send_command(minus) self._level = max(0, self._level - steps) self.async_write_ha_state() @@ -141,16 +140,10 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): """Reset to off with `SPEED_COUNT` minus presses, then climb to level.""" minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) for _ in range(SPEED_COUNT): - await self._async_send(minus) + await self._send_command(minus) if level > 0: plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) for _ in range(level): - await self._async_send(plus) + await self._send_command(plus) self._level = level self.async_write_ha_state() - - async def _async_send(self, command: NovyCookerHoodCommand) -> None: - """Send a single RF command via the configured transmitter.""" - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py index 597c4873fcad..8a456b396723 100644 --- a/homeassistant/components/novy_cooker_hood/light.py +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -5,13 +5,13 @@ from typing import Any, override from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.components.radio_frequency import async_send_command from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import CONF_TRANSMITTER from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 @@ -36,6 +36,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the light.""" super().__init__(entry) + self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER] self._code = entry.data[CONF_CODE] self._attr_unique_id = entry.entry_id @@ -63,6 +64,4 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): async def _async_send_light(self) -> None: """Send the light toggle command via the configured transmitter.""" command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code) - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) + await self._send_command(command) diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py index 087d9e890706..bf4ba5ca5fa6 100644 --- a/homeassistant/components/radio_frequency/__init__.py +++ b/homeassistant/components/radio_frequency/__init__.py @@ -6,9 +6,9 @@ import logging from rf_protocols import ModulationType, RadioFrequencyCommand from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -18,11 +18,14 @@ from .entity import ( RadioFrequencyTransmitterEntity, RadioFrequencyTransmitterEntityDescription, ) +from .helpers import RadioFrequencyTransmitterConsumerEntity, async_send_command __all__ = [ "DATA_COMPONENT", "DOMAIN", "ModulationType", + "RadioFrequencyCommand", + "RadioFrequencyTransmitterConsumerEntity", "RadioFrequencyTransmitterEntity", "RadioFrequencyTransmitterEntityDescription", "async_get_transmitters", @@ -95,60 +98,3 @@ def async_get_transmitters( if entity.supports_modulation(modulation) and entity.supports_frequency(frequency) ] - - -async def async_send_command( - hass: HomeAssistant, - entity_id_or_uuid: str, - command: RadioFrequencyCommand, - context: Context | None = None, -) -> None: - """Send an RF command to the specified radio_frequency entity. - - Raises: - vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity - registry UUID. - HomeAssistantError: If the radio_frequency component is not loaded or the - resolved entity is not found. - """ - component = hass.data.get(DATA_COMPONENT) - if component is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="component_not_loaded", - ) - - ent_reg = er.async_get(hass) - entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) - entity = component.get_entity(entity_id) - if entity is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="entity_not_found", - translation_placeholders={"entity_id": entity_id}, - ) - - if not entity.supports_frequency(command.frequency): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unsupported_frequency", - translation_placeholders={ - "entity_id": entity_id, - "frequency": str(command.frequency), - }, - ) - - if not entity.supports_modulation(command.modulation): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unsupported_modulation", - translation_placeholders={ - "entity_id": entity_id, - "modulation": command.modulation, - }, - ) - - if context is not None: - entity.async_set_context(context) - - await entity.async_send_command_internal(command) diff --git a/homeassistant/components/radio_frequency/helpers.py b/homeassistant/components/radio_frequency/helpers.py new file mode 100644 index 000000000000..1fbefbfcfd52 --- /dev/null +++ b/homeassistant/components/radio_frequency/helpers.py @@ -0,0 +1,171 @@ +"""Helper base entities for integrations that consume RF transmitters.""" + +import logging +from typing import override + +from rf_protocols import RadioFrequencyCommand + +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import ( + async_track_entity_registry_updated_event, + async_track_state_change_event, +) + +from .const import DATA_COMPONENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_send_command( + hass: HomeAssistant, + entity_id_or_uuid: str, + command: RadioFrequencyCommand, + context: Context | None = None, +) -> None: + """Send an RF command to the specified radio_frequency entity. + + Raises: + vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity + registry UUID. + HomeAssistantError: If the radio_frequency component is not loaded or the + resolved entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + if not entity.supports_frequency(command.frequency): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_frequency", + translation_placeholders={ + "entity_id": entity_id, + "frequency": str(command.frequency), + }, + ) + + if not entity.supports_modulation(command.modulation): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_modulation", + translation_placeholders={ + "entity_id": entity_id, + "modulation": command.modulation, + }, + ) + + if context is not None: + entity.async_set_context(context) + + await entity.async_send_command_internal(command) + + +class RadioFrequencyTransmitterConsumerEntity(Entity): + """Base entity for integrations that send commands via an RF transmitter. + + Tracks the availability of the underlying RF transmitter entity. + """ + + _attr_should_poll = False + _rf_transmitter_entity_id_or_uuid: str + _rf_unsubscribes: list[CALLBACK_TYPE] + + @override + async def async_added_to_hass(self) -> None: + """Subscribe to RF entity state and rename events.""" + await super().async_added_to_hass() + + self._rf_unsubscribes = [] + self.async_on_remove(self._async_unsubscribe_rf) + self._async_track_rf_entity( + er.async_validate_entity_id( + er.async_get(self.hass), self._rf_transmitter_entity_id_or_uuid + ) + ) + + @callback + def _async_unsubscribe_rf(self) -> None: + """Tear down the current transmitter subscriptions.""" + while self._rf_unsubscribes: + self._rf_unsubscribes.pop()() + + @callback + def _async_track_rf_entity(self, entity_id: str) -> None: + """Track state and rename events for the resolved transmitter entity_id.""" + self._async_unsubscribe_rf() + self._rf_unsubscribes.append( + async_track_state_change_event( + self.hass, [entity_id], self._async_rf_state_changed + ) + ) + self._rf_unsubscribes.append( + async_track_entity_registry_updated_event( + self.hass, entity_id, self._async_rf_registry_updated + ) + ) + rf_state = self.hass.states.get(entity_id) + self._attr_available = ( + rf_state is not None and rf_state.state != STATE_UNAVAILABLE + ) + + async def _send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command through the RF transmitter entity.""" + await async_send_command( + self.hass, + self._rf_transmitter_entity_id_or_uuid, + command, + context=self._context, + ) + + @callback + def _async_rf_registry_updated( + self, event: Event[er.EventEntityRegistryUpdatedData] + ) -> None: + """Re-track the transmitter when it is renamed.""" + data = event.data + if data["action"] != "update": + return + if ATTR_ENTITY_ID not in data["changes"]: + return + self._async_track_rf_entity(data[ATTR_ENTITY_ID]) + self.async_write_ha_state() + + @callback + def _async_rf_state_changed(self, event: Event[EventStateChangedData]) -> None: + """Handle RF entity state changes.""" + new_state = event.data["new_state"] + rf_available = new_state is not None and new_state.state != STATE_UNAVAILABLE + if rf_available != self.available: + _LOGGER.info( + "Radio frequency entity %s used by %s is %s", + event.data["entity_id"], + self.entity_id, + "available" if rf_available else "unavailable", + ) + + self._attr_available = rf_available + self.async_write_ha_state() diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py index f117f76b1f2f..7a963611f320 100644 --- a/tests/components/novy_cooker_hood/test_light.py +++ b/tests/components/novy_cooker_hood/test_light.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import entity_registry as er from .conftest import TRANSMITTER_ENTITY_ID @@ -96,3 +97,27 @@ async def test_entity_follows_transmitter_availability( await assert_availability_follows_source_entity( hass, ENTITY_ID, TRANSMITTER_ENTITY_ID ) + + +async def test_tracking_follows_transmitter_rename( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """Availability tracking and sending survive a transmitter entity rename.""" + new_transmitter_id = "radio_frequency.renamed_transmitter" + entity_registry.async_update_entity( + TRANSMITTER_ENTITY_ID, new_entity_id=new_transmitter_id + ) + await hass.async_block_till_done() + + await assert_availability_follows_source_entity(hass, ENTITY_ID, new_transmitter_id) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert len(mock_rf_entity.send_command_calls) == 1 From 075e5666b2817cf931c0345dc46ed5ce13638619 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 20:09:53 +0200 Subject: [PATCH 404/707] Remove migrated dead code from helpers/llm.py (#176082) Co-authored-by: Claude --- homeassistant/components/homeassistant/llm.py | 13 +- homeassistant/helpers/llm.py | 462 +----------- tests/components/homeassistant/test_llm.py | 4 +- tests/components/script/test_llm.py | 8 + tests/helpers/test_llm.py | 703 +----------------- 5 files changed, 25 insertions(+), 1165 deletions(-) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index 70a1f7557efb..11c3c9e30caa 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -18,13 +18,7 @@ from homeassistant.helpers import ( entity_registry as er, intent, ) -from homeassistant.helpers.llm import ( - LLM_API_ASSIST, - NO_ENTITIES_PROMPT, - LLMContext, - Tool, - ToolInput, -) +from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.json import JsonObjectType @@ -34,6 +28,11 @@ from .exposed_entities import async_should_expose CALENDAR_DOMAIN = "calendar" SCRIPT_DOMAIN = "script" +NO_ENTITIES_PROMPT = ( + "Only if the user wants to control a device, tell them to expose entities " + "to their voice assistant in Home Assistant." +) + DYNAMIC_CONTEXT_PROMPT = ( "You ARE equipped to answer questions about the" " current state of\n" diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 86b5726a1c53..d3f10a65a573 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -3,34 +3,21 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field as dc_field -from datetime import timedelta -from decimal import Decimal -from enum import Enum -from operator import attrgetter -from typing import Any, cast, override +from typing import Any, override import slugify as unicode_slug import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert -from homeassistant.components.calendar import ( - DOMAIN as CALENDAR_DOMAIN, - SERVICE_GET_EVENTS, -) -from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN -from homeassistant.components.sensor import async_rounded_state -from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices from homeassistant.const import ( ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, - EntityStateAttribute, ) -from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id +from homeassistant.core import Context, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType from homeassistant.util.ulid import ulid_now @@ -67,11 +54,6 @@ Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. """ -NO_ENTITIES_PROMPT = ( - "Only if the user wants to control a device, tell them to expose entities " - "to their voice assistant in Home Assistant." -) - @deprecated_function("an empty string", breaks_in_ha_version="2027.2") @callback @@ -424,119 +406,6 @@ class MergedAPI(API): return merged -def _get_exposed_entities( - hass: HomeAssistant, - assistant: str, - include_state: bool = True, -) -> dict[str, dict[str, dict[str, Any]]]: - """Get exposed entities. - - Splits out calendars and scripts. - """ - area_registry = ar.async_get(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - interesting_attributes = { - "temperature", - "current_temperature", - "temperature_unit", - "brightness", - "humidity", - "unit_of_measurement", - "device_class", - "current_position", - "percentage", - "volume_level", - "media_title", - "media_artist", - "media_album_name", - } - - entities = {} - data: dict[str, dict[str, Any]] = { - SCRIPT_DOMAIN: {}, - CALENDAR_DOMAIN: {}, - } - - for state in sorted(hass.states.async_all(), key=attrgetter("name")): - if not async_should_expose(hass, assistant, state.entity_id): - continue - - entity_entry = entity_registry.async_get(state.entity_id) - device_entry = ( - device_registry.async_get(entity_entry.device_id) - if entity_entry is not None and entity_entry.device_id is not None - else None - ) - names = intent.async_get_entity_aliases(hass, entity_entry, state=state) - area_names = [] - - if entity_entry is not None: - if ( - entity_entry.area_id is not None - and (area_entry := area_registry.async_get_area(entity_entry.area_id)) - is not None - ): - # Entity is in area - area_names.append(area_entry.name) - area_names.extend(sorted(area_entry.aliases)) - elif device_entry is not None: - # Check device area - if ( - device_entry.area_id is not None - and ( - area_entry := area_registry.async_get_area(device_entry.area_id) - ) - is not None - ): - area_names.append(area_entry.name) - area_names.extend(sorted(area_entry.aliases)) - - info: dict[str, Any] = { - "names": ", ".join(names), - "domain": state.domain, - } - - if include_state: - info["state"] = state.state - - # Format numeric states with configured display precision - if state.domain == "sensor": - info["state"] = async_rounded_state(hass, state.entity_id, state) - - # Convert timestamp device_class states from UTC to local time - if ( - state.attributes.get(EntityStateAttribute.DEVICE_CLASS) == "timestamp" - and state.state - ): - if (parsed_utc := dt_util.parse_datetime(state.state)) is not None: - info["state"] = dt_util.as_local(parsed_utc).isoformat() - - if area_names: - info["areas"] = ", ".join(area_names) - - if include_state and ( - attributes := { - str(attr_name): ( - str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value - ) - for attr_name, attr_value in state.attributes.items() - if attr_name in interesting_attributes - } - ): - info["attributes"] = attributes - - if state.domain in data: - data[state.domain][state.entity_id] = info - else: - entities[state.entity_id] = info - - data["entities"] = entities - return data - - def selector_serializer(schema: Any) -> Any: # noqa: C901 """Convert selectors into OpenAPI schema.""" if not isinstance(schema, selector.Selector): @@ -821,330 +690,3 @@ class ActionTool(Tool): ) return {"success": True, "result": result} - - -class ScriptTool(ActionTool): - """LLM Tool representing a Script.""" - - def __init__( - self, - hass: HomeAssistant, - script_entity_id: str, - ) -> None: - """Init the class.""" - script_name = split_entity_id(script_entity_id)[1] - - action = script_name - entity_registry = er.async_get(hass) - entity_entry = entity_registry.async_get(script_entity_id) - if entity_entry and entity_entry.unique_id: - action = entity_entry.unique_id - - super().__init__(hass, SCRIPT_DOMAIN, action) - - self.name = script_name - if self.name[0].isdigit(): - self.name = "_" + self.name - - -class CalendarGetEventsTool(Tool): - """LLM Tool allowing querying a calendar.""" - - name = "calendar_get_events" - description = ( - "Get events from a calendar. " - "When asked if something happens, search the whole week. " - "Results are RFC 5545 which means 'end' is exclusive." - ) - - def __init__(self, calendars: list[str]) -> None: - """Init the get events tool.""" - self.parameters = vol.Schema( - { - vol.Required("calendar"): vol.In(calendars), - vol.Required("range"): vol.In(["today", "week"]), - } - ) - - @override - async def async_call( - self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: - """Query a calendar.""" - data = self.parameters(tool_input.tool_args) - result = intent.async_match_targets( - hass, - intent.MatchTargetsConstraints( - name=data["calendar"], - domains=[CALENDAR_DOMAIN], - assistant=llm_context.assistant, - ), - ) - if not result.is_match: - return {"success": False, "error": "Calendar not found"} - - entity_id = result.states[0].entity_id - if data["range"] == "today": - start = dt_util.now() - end = dt_util.start_of_local_day() + timedelta(days=1) - elif data["range"] == "week": - start = dt_util.now() - end = dt_util.start_of_local_day() + timedelta(days=7) - - service_data = { - "entity_id": entity_id, - "start_date_time": start.isoformat(), - "end_date_time": end.isoformat(), - } - - service_result = await hass.services.async_call( - CALENDAR_DOMAIN, - SERVICE_GET_EVENTS, - service_data, - context=llm_context.context, - blocking=True, - return_response=True, - ) - - events = [ - event if "T" in event["start"] else {**event, "all_day": True} - for event in cast(dict, service_result)[entity_id]["events"] - ] - - return {"success": True, "result": events} - - -class TodoGetItemsTool(Tool): - """LLM Tool allowing querying a to-do list.""" - - name = "todo_get_items" - description = ( - "Query a to-do list to find out what items are on it. " - "Use this to answer questions like " - "'What's on my task list?' or " - "'Read my grocery list'. " - "Filters items by status (needs_action, completed, all)." - ) - - def __init__(self, todo_lists: list[str]) -> None: - """Init the get items tool.""" - self.parameters = vol.Schema( - { - vol.Required("todo_list"): vol.In(todo_lists), - vol.Optional( - "status", - description=( - "Filter returned items by status," - " by default returns incomplete" - " items" - ), - default="needs_action", - ): vol.In(["needs_action", "completed", "all"]), - } - ) - - @override - async def async_call( - self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: - """Query a to-do list.""" - data = self.parameters(tool_input.tool_args) - result = intent.async_match_targets( - hass, - intent.MatchTargetsConstraints( - name=data["todo_list"], - domains=[TODO_DOMAIN], - assistant=llm_context.assistant, - ), - ) - if not result.is_match: - return {"success": False, "error": "To-do list not found"} - entity_id = result.states[0].entity_id - service_data: dict[str, Any] = {"entity_id": entity_id} - if status := data.get("status"): - if status == "all": - service_data["status"] = ["needs_action", "completed"] - else: - service_data["status"] = [status] - service_result = await hass.services.async_call( - TODO_DOMAIN, - TodoServices.GET_ITEMS, - service_data, - context=llm_context.context, - blocking=True, - return_response=True, - ) - if not service_result: - return {"success": False, "error": "To-do list not found"} - items = cast(dict, service_result)[entity_id]["items"] - return {"success": True, "result": items} - - -def _live_context_match_error( - match_result: intent.MatchTargetsResult, - name_filter: str | None, - area_filter: str | None, - domain_filter: list[str] | None, -) -> str: - """Build an actionable error message for a failed GetLiveContext match.""" - reason = match_result.no_match_reason - if reason is intent.MatchFailedReason.INVALID_AREA: - return f"Area '{match_result.no_match_name}' does not exist" - if reason is intent.MatchFailedReason.NAME: - return f"No exposed entities matched name '{name_filter}'" - if reason is intent.MatchFailedReason.AREA: - return f"No exposed entities found in area '{area_filter}'" - if reason is intent.MatchFailedReason.DOMAIN: - domains = ", ".join(domain_filter) if domain_filter else "" - return f"No exposed entities found in domain(s): {domains}" - return "No entities matched the provided filter" - - -class GetLiveContextTool(Tool): - """Tool for getting the current state of exposed entities. - - This returns state for all entities that have been exposed to - the assistant. This is different than the GetState intent, which - returns state for entities based on intent parameters. - """ - - name = "GetLiveContext" - description = ( - "Provides real-time information about the" - " CURRENT state, value, or mode of devices," - " sensors, entities, or areas. " - "Use this tool for: " - "1. Answering questions about current" - " conditions (e.g., 'Is the light on?'). " - "2. As the first step in conditional actions" - " (e.g., 'If the weather is rainy, turn off" - " sprinklers' requires checking the weather" - " first). " - "You may filter for devices by name, domain," - " and area, including combining those" - " filters. " - "Prefer filtering by domain when searching" - " for multiple devices of the same type." - ) - parameters = vol.Schema( - { - vol.Optional( - "name", - description="Filter entities by name or alias (case-insensitive).", - ): cv.string, - vol.Optional( - "domain", - description=( - "Filter entities by domain" - " (e.g. 'light', 'sensor')." - " Accepts a single domain or a list." - ), - ): vol.Any(cv.string, [cv.string]), - vol.Optional( - "area", - description="Filter entities by area name or alias (case-insensitive).", - ): cv.string, - } - ) - - @override - async def async_call( - self, - hass: HomeAssistant, - tool_input: ToolInput, - llm_context: LLMContext, - ) -> JsonObjectType: - """Get the current state of exposed entities.""" - args = self.parameters(tool_input.tool_args) - exposed_entities = _get_exposed_entities(hass, llm_context.assistant) - - if not exposed_entities["entities"]: - return {"success": False, "error": NO_ENTITIES_PROMPT} - - name_filter = args.get("name") - area_filter = args.get("area") - domain_filter = args.get("domain") - - if isinstance(domain_filter, str): - domain_filter = [domain_filter] - - if domain_filter is not None: - domain_filter = [ - normalized_domain - for domain in domain_filter - if (normalized_domain := domain.strip().lower()) - ] - - if name_filter or area_filter or domain_filter: - exposed_states = [ - state - for entity_id in exposed_entities["entities"] - if (state := hass.states.get(entity_id)) is not None - ] - match_result = intent.async_match_targets( - hass, - intent.MatchTargetsConstraints( - name=name_filter, - area_name=area_filter, - domains=domain_filter, - # This tool only returns context, so multiple entities - # sharing a name (e.g. "AC" in two areas) should all be - # returned rather than failing as an ambiguous match. - allow_duplicate_names=True, - ), - states=exposed_states, - ) - - if not match_result.is_match: - return { - "success": False, - "error": _live_context_match_error( - match_result, name_filter, area_filter, domain_filter - ), - } - - matched_ids = {state.entity_id for state in match_result.states} - entities = [ - info - for entity_id, info in exposed_entities["entities"].items() - if entity_id in matched_ids - ] - else: - entities = list(exposed_entities["entities"].values()) - - prompt = [ - "Live Context: An overview of the areas" - " and the devices in this smart home:", - yaml_util.dump(entities), - ] - return { - "success": True, - "result": "\n".join(prompt), - } - - -class GetDateTimeTool(Tool): - """Tool for getting the current date and time.""" - - name = "GetDateTime" - description = "Provides the current date and time." - - @override - async def async_call( - self, - hass: HomeAssistant, - tool_input: ToolInput, - llm_context: LLMContext, - ) -> JsonObjectType: - """Get the current date and time.""" - now = dt_util.now() - - return { - "success": True, - "result": { - "date": now.strftime("%Y-%m-%d"), - "time": now.strftime("%H:%M:%S"), - "timezone": now.strftime("%Z"), - "weekday": now.strftime("%A"), - }, - } diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index 019a44543f4f..53cb35748031 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -66,7 +66,7 @@ async def test_prompt_no_entities(hass: HomeAssistant) -> None: """Test the platform contributes the no-entities prompt when nothing is exposed.""" async_expose_entity(hass, "conversation", ENTITY_ID, False) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - assert result.prompt == llm.NO_ENTITIES_PROMPT + assert result.prompt == ha_llm.NO_ENTITIES_PROMPT async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None: @@ -79,7 +79,7 @@ async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None response = await tool.async_call( hass, llm.ToolInput("GetLiveContext", {}), llm_context ) - assert response == {"success": False, "error": llm.NO_ENTITIES_PROMPT} + assert response == {"success": False, "error": ha_llm.NO_ENTITIES_PROMPT} async def test_get_live_context_tool(hass: HomeAssistant) -> None: diff --git a/tests/components/script/test_llm.py b/tests/components/script/test_llm.py index 2783bdfcf0d7..7d00d29aa7cf 100644 --- a/tests/components/script/test_llm.py +++ b/tests/components/script/test_llm.py @@ -35,6 +35,7 @@ async def setup_integrations(hass: HomeAssistant) -> None: }, }, "unexposed_script": {"sequence": []}, + "123456": {"sequence": []}, } }, ) @@ -84,3 +85,10 @@ async def test_script_tool_call(hass: HomeAssistant) -> None: hass, llm.ToolInput("test_script", {"beer": 1}), llm_context ) assert response == {"success": True, "result": {"drinks": 2}} + + +async def test_script_tool_name_not_started_with_digit(hass: HomeAssistant) -> None: + """Test a script whose id starts with a digit gets a valid tool name.""" + async_expose_entity(hass, "conversation", "script.123456", True) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "_123456" in [tool.name for tool in result.tools] diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 59c1c5dcef54..d2cf638da457 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1,18 +1,16 @@ """Tests for the llm helpers.""" -from datetime import timedelta from decimal import Decimal from unittest.mock import patch import pytest import voluptuous as vol -from homeassistant.components import calendar, todo from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.script import ScriptConfig from homeassistant.const import EntityStateAttribute -from homeassistant.core import Context, HomeAssistant, State, SupportsResponse +from homeassistant.core import Context, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, @@ -25,10 +23,9 @@ from homeassistant.helpers import ( selector, ) from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType -from tests.common import MockConfigEntry, async_mock_service +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) @@ -773,348 +770,16 @@ Static Context: An overview of the areas and the devices in this smart home: ) -async def test_get_live_context_tool_filter( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - area_registry: ar.AreaRegistry, -) -> None: - """Test the filter parameters of the GetLiveContext tool.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - - office = area_registry.async_create("Office") - area_registry.async_update(office.id, aliases={"Workspace"}) - area_registry.async_create("Kitchen") - - office_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "office-1")}, - suggested_area="Office", - ) - kitchen_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "kitchen-1")}, - suggested_area="Kitchen", - ) - - office_light = entity_registry.async_get_or_create( - "light", - "test", - "office_light", - original_name="Office Light", - device_id=office_device.id, - suggested_object_id="office_light", - ) - kitchen_light = entity_registry.async_get_or_create( - "light", - "test", - "kitchen_light", - original_name="Kitchen Light", - device_id=kitchen_device.id, - suggested_object_id="kitchen_light", - ) - office_switch = entity_registry.async_get_or_create( - "switch", - "test", - "office_switch", - original_name="Office Switch", - device_id=office_device.id, - suggested_object_id="office_switch", - ) - front_door = entity_registry.async_get_or_create( - "lock", - "test", - "front_door", - original_name="Front Door", - suggested_object_id="front_door", - ) - # Two entities sharing the same name in different areas - office_ac = entity_registry.async_get_or_create( - "climate", - "test", - "office_ac", - original_name="AC", - device_id=office_device.id, - suggested_object_id="office_ac", - ) - kitchen_ac = entity_registry.async_get_or_create( - "climate", - "test", - "kitchen_ac", - original_name="AC", - device_id=kitchen_device.id, - suggested_object_id="kitchen_ac", - ) - entity_registry.async_update_entity( - kitchen_light.entity_id, aliases=[er.COMPUTED_NAME, "Cooking Lamp"] - ) - - for entity_id in ( - office_light.entity_id, - kitchen_light.entity_id, - office_switch.entity_id, - front_door.entity_id, - office_ac.entity_id, - kitchen_ac.entity_id, - ): - async_expose_entity(hass, "conversation", entity_id, True) - - hass.states.async_set(office_light.entity_id, "on") - hass.states.async_set(kitchen_light.entity_id, "off") - hass.states.async_set(office_switch.entity_id, "on") - hass.states.async_set(front_door.entity_id, "locked") - hass.states.async_set(office_ac.entity_id, "cool") - hass.states.async_set(kitchen_ac.entity_id, "heat") - - api = await llm.async_get_api(hass, "assist", llm_context) - - # Filter by area and domain (example 1) - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": "Office", "domain": "light"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] - - # Filter by name (example 2) - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Front Door"}, - ) - ) - assert result["success"] is True - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - - # Name filter is case insensitive - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "front door"}, - ) - ) - assert result["success"] is True - assert "Front Door" in result["result"] - - # Area filter matches area aliases - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": "workspace"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] - - # Domain filter accepts a list - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"domain": ["switch", "lock"]}, - ) - ) - assert result["success"] is True - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] - - # Domain filter is case insensitive - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"domain": "Light"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] - - # No filters returns all exposed entities - result = await api.async_call_tool( - llm.ToolInput(tool_name="GetLiveContext", tool_args={}) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] - - # Filter that matches nothing returns a descriptive error - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Does Not Exist"}, - ) - ) - assert result == { - "success": False, - "error": "No exposed entities matched name 'Does Not Exist'", - } - - # Name filter strips surrounding whitespace - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": " Front Door "}, - ) - ) - assert result["success"] is True - assert "Front Door" in result["result"] - - # Area filter strips surrounding whitespace - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": " Office "}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - - # Name filter accepts entity_id - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": office_light.entity_id}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - - # Area filter accepts area_id - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": office.id}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] - - # Name filter matches entity aliases - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "cooking lamp"}, - ) - ) - assert result["success"] is True - assert "Kitchen Light" in result["result"] - assert "Office Light" not in result["result"] - - # Combining name + area narrows the result - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Office Light", "area": "Office"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" not in result["result"] - - # Combining name + area returns the failing constraint in the error - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Office Light", "area": "Kitchen"}, - ) - ) - assert result == { - "success": False, - "error": "No exposed entities found in area 'Kitchen'", - } - - # Unknown area distinguishes "invalid area" from "no entities in area" - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": "Garage"}, - ) - ) - assert result == { - "success": False, - "error": "Area 'Garage' does not exist", - } - - # Unknown domain reports which domain(s) failed - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"domain": "fan"}, - ) - ) - assert result == { - "success": False, - "error": "No exposed entities found in domain(s): fan", - } - - # Entities sharing a name are all returned rather than failing as an - # ambiguous match, since this tool only returns context. - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "AC"}, - ) - ) - assert result["success"] is True - assert result["result"].count("domain: climate") == 2 - assert "Office" in result["result"] - assert "Kitchen" in result["result"] - - # Combining a shared name with an area narrows to the single match - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "AC", "area": "Kitchen"}, - ) - ) - assert result["success"] is True - assert result["result"].count("domain: climate") == 1 - assert "Kitchen" in result["result"] - assert "Office" not in result["result"] - - -async def test_script_tool( +async def test_action_tool( hass: HomeAssistant, entity_registry: er.EntityRegistry, area_registry: ar.AreaRegistry, floor_registry: fr.FloorRegistry, ) -> None: - """Test ScriptTool for the assist API.""" + """Test ActionTool schema, area/floor resolution and parameter caching. + + Exercised through scripts, the only ActionTool the assist API builds. + """ assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "intent", {}) context = Context() @@ -1324,45 +989,6 @@ async def test_script_tool( } -async def test_script_tool_name(hass: HomeAssistant) -> None: - """Test that script tool name is not started with a digit.""" - assert await async_setup_component(hass, "homeassistant", {}) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - - # Create a script with a unique ID - assert await async_setup_component( - hass, - "script", - { - "script": { - "123456": { - "description": "This is a test script", - "sequence": [], - "fields": { - "beer": {"description": "Number of beers", "required": True}, - }, - }, - } - }, - ) - async_expose_entity(hass, "conversation", "script.123456", True) - - api = await llm.async_get_api(hass, "assist", llm_context) - - tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] - assert len(tools) == 1 - - tool = tools[0] - assert tool.name == "_123456" - - async def test_selector_serializer( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -1638,254 +1264,6 @@ async def test_selector_serializer( } -async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: - """Test the calendar get events tool.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "calendar", {}) - hass.states.async_set( - "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} - ) - async_expose_entity(hass, "conversation", "calendar.test_calendar", True) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - api = await llm.async_get_api(hass, "assist", llm_context) - tool = next( - (tool for tool in api.tools if tool.name == "calendar_get_events"), None - ) - assert tool is not None - assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] - - calls = async_mock_service( - hass, - domain=calendar.DOMAIN, - service=calendar.SERVICE_GET_EVENTS, - schema=calendar.SERVICE_GET_EVENTS_SCHEMA, - response={ - "calendar.test_calendar": { - "events": [ - { - "start": "2025-09-17", - "end": "2025-09-18", - "summary": "Home Assistant 12th birthday", - "description": "", - }, - { - "start": "2025-09-17T14:00:00-05:00", - "end": "2025-09-18T15:00:00-05:00", - "summary": "Champagne", - "description": "", - }, - ] - } - }, - supports_response=SupportsResponse.ONLY, - ) - - tool_input = llm.ToolInput( - tool_name="calendar_get_events", - tool_args={ - "calendar": "Mock Calendar Name", - "range": "today", - }, - ) - now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): - response = await api.async_call_tool(tool_input) - - assert len(calls) == 1 - call = calls[0] - assert call.domain == calendar.DOMAIN - assert call.service == calendar.SERVICE_GET_EVENTS - assert call.data == { - "entity_id": ["calendar.test_calendar"], - "start_date_time": now, - "end_date_time": dt_util.start_of_local_day() + timedelta(days=1), - } - - assert response == { - "success": True, - "result": [ - { - "start": "2025-09-17", - "end": "2025-09-18", - "summary": "Home Assistant 12th birthday", - "description": "", - "all_day": True, - }, - { - "start": "2025-09-17T14:00:00-05:00", - "end": "2025-09-18T15:00:00-05:00", - "summary": "Champagne", - "description": "", - }, - ], - } - - tool_input.tool_args["range"] = "week" - with patch("homeassistant.util.dt.now", return_value=now): - response = await api.async_call_tool(tool_input) - - assert len(calls) == 2 - call = calls[1] - assert call.data == { - "entity_id": ["calendar.test_calendar"], - "start_date_time": now, - "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), - } - - -async def test_todo_get_items_tool(hass: HomeAssistant) -> None: - """Test the todo get items tool.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "todo", {}) - hass.states.async_set( - "todo.test_list", "0", {"friendly_name": "Mock Todo List Name"} - ) - async_expose_entity(hass, "conversation", "todo.test_list", True) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - api = await llm.async_get_api(hass, "assist", llm_context) - tool = next((tool for tool in api.tools if tool.name == "todo_get_items"), None) - assert tool is not None - assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] - - calls = async_mock_service( - hass, - domain=todo.DOMAIN, - service=todo.TodoServices.GET_ITEMS, - schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), - response={ - "todo.test_list": { - "items": [ - { - "uid": "1234", - "summary": "Buy milk", - "status": "needs_action", - }, - { - "uid": "5678", - "summary": "Call mom", - "status": "needs_action", - "due": "2025-09-17", - "description": "Remember birthday", - }, - ] - } - }, - ) - - # Test without status filter (defaults to needs_action) - result = await tool.async_call( - hass, - llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), - llm_context, - ) - - assert len(calls) == 1 - assert calls[0].data == { - "entity_id": ["todo.test_list"], - "status": ["needs_action"], - } - assert result == { - "success": True, - "result": [ - { - "uid": "1234", - "status": "needs_action", - "summary": "Buy milk", - }, - { - "uid": "5678", - "status": "needs_action", - "summary": "Call mom", - "due": "2025-09-17", - "description": "Remember birthday", - }, - ], - } - - # Test that the status filter is passed correctly to the service call. - # We don't assert on the response since it is fixed above. - calls.clear() - result = await tool.async_call( - hass, - llm.ToolInput( - "todo_get_items", - {"todo_list": "Mock Todo List Name", "status": "completed"}, - ), - llm_context, - ) - assert len(calls) == 1 - assert calls[0].data == { - "entity_id": ["todo.test_list"], - "status": ["completed"], - } - - # Test that the status filter is passed correctly to the service call. - # We don't assert on the response since it is fixed above. - calls.clear() - result = await tool.async_call( - hass, - llm.ToolInput( - "todo_get_items", - {"todo_list": "Mock Todo List Name", "status": "all"}, - ), - llm_context, - ) - assert len(calls) == 1 - assert calls[0].data == { - "entity_id": ["todo.test_list"], - "status": ["needs_action", "completed"], - } - - -async def test_get_date_time_tool(hass: HomeAssistant) -> None: - """Test the GetDateTime tool.""" - - assert await async_setup_component(hass, "homeassistant", {}) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - api = await llm.async_get_api(hass, "assist", llm_context) - tool = next((tool for tool in api.tools if tool.name == "GetDateTime"), None) - assert tool is not None - - now = dt_util.parse_datetime("2025-09-22 12:30:45Z") - - with patch("homeassistant.util.dt.now", return_value=now): - result = await tool.async_call( - hass, - llm.ToolInput("GetDateTime", {}), - llm_context, - ) - assert result == { - "success": True, - "result": { - "date": "2025-09-22", - "time": "12:30:45", - "timezone": "UTC", - "weekday": "Monday", - }, - } - - async def test_no_tools_exposed(hass: HomeAssistant) -> None: """Test that tools are not exposed when no entities are exposed.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1956,73 +1334,6 @@ This is prompt 2 assert result == {"result": {"Tool_2": {"arg2": "value2"}}} -async def test_get_exposed_entities_timestamp_conversion(hass: HomeAssistant) -> None: - """Test that _get_exposed_entities converts timestamp states to local time.""" - assert await async_setup_component(hass, "homeassistant", {}) - - # Set the timezone to something other than UTC to ensure conversion is tested - await hass.config.async_set_time_zone("America/New_York") - - # Set up a timestamp sensor with UTC time - utc_timestamp = "2024-01-15T10:30:00+00:00" - hass.states.async_set( - "sensor.test_timestamp", - utc_timestamp, - {"device_class": "timestamp", "friendly_name": "Test Timestamp"}, - ) - - # Also test with a non-timestamp sensor to ensure it's not affected - hass.states.async_set( - "sensor.regular_sensor", - "2024-01-15T10:30:00+00:00", - {"friendly_name": "Regular Sensor"}, # No device_class - ) - - # And test with invalid/empty timestamp - hass.states.async_set( - "sensor.invalid_timestamp", - "not-a-timestamp", - {"device_class": "timestamp", "friendly_name": "Invalid Timestamp"}, - ) - - hass.states.async_set( - "sensor.empty_timestamp", - "", - {"device_class": "timestamp", "friendly_name": "Empty Timestamp"}, - ) - - # Expose the entities - async_expose_entity(hass, "conversation", "sensor.test_timestamp", True) - async_expose_entity(hass, "conversation", "sensor.regular_sensor", True) - async_expose_entity(hass, "conversation", "sensor.invalid_timestamp", True) - async_expose_entity(hass, "conversation", "sensor.empty_timestamp", True) - - # Call _get_exposed_entities - exposed = llm._get_exposed_entities(hass, "conversation", include_state=True) - - # Check the converted timestamp - sensor_info = exposed["entities"]["sensor.test_timestamp"] - - assert sensor_info["state"] == "2024-01-15T05:30:00-05:00" - # Regular sensor without device_class should keep original value - regular_info = exposed["entities"]["sensor.regular_sensor"] - assert regular_info["state"] == "2024-01-15T10:30:00+00:00" # Unchanged - - # Invalid timestamp should remain as-is - invalid_info = exposed["entities"]["sensor.invalid_timestamp"] - assert invalid_info["state"] == "not-a-timestamp" - - # Empty timestamp should remain empty - empty_info = exposed["entities"]["sensor.empty_timestamp"] - assert empty_info["state"] == "" - - # Test with include_state=False to ensure no conversion happens - exposed_no_state = llm._get_exposed_entities( - hass, "conversation", include_state=False - ) - assert "state" not in exposed_no_state["entities"]["sensor.test_timestamp"] - - async def test_deprecated_async_render_no_api_prompt( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From d9ea09bd8643952d2252e8c6033a9ca9f06a4ead Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:11:14 +0200 Subject: [PATCH 405/707] Migrate input_text entity attributes to StrEnum (#175750) --- homeassistant/components/input_text/__init__.py | 7 ++++--- homeassistant/components/input_text/const.py | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/input_text/const.py diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 061f5f2af85a..577f703c3a3a 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.components.text import TextEntity from homeassistant.const import ( # noqa: F401 - ATTR_EDITABLE, ATTR_MODE, CONF_ICON, CONF_ID, @@ -25,6 +24,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputTextEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "input_text" @@ -192,7 +193,7 @@ class InputTextStorageCollection(collection.DictStorageCollection): class InputText(collection.CollectionEntity, TextEntity, RestoreEntity): """Represent a text box.""" - _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _unrecorded_attributes = frozenset({InputTextEntityStateAttribute.EDITABLE}) _attr_should_poll = False editable: bool @@ -234,7 +235,7 @@ class InputText(collection.CollectionEntity, TextEntity, RestoreEntity): @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return {ATTR_EDITABLE: self.editable} + return {InputTextEntityStateAttribute.EDITABLE: self.editable} @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/input_text/const.py b/homeassistant/components/input_text/const.py new file mode 100644 index 000000000000..25760de29eab --- /dev/null +++ b/homeassistant/components/input_text/const.py @@ -0,0 +1,9 @@ +"""Constants for the input_text integration.""" + +from enum import StrEnum + + +class InputTextEntityStateAttribute(StrEnum): + """State attributes for input text entities.""" + + EDITABLE = "editable" From e94e4d2c63ef42264575967a6ba6454c3b96c492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 9 Jul 2026 19:36:21 +0100 Subject: [PATCH 406/707] Add pr template to copilot instructions (#176133) --- .github/copilot-instructions.md | 126 ++++++++++++++++++++++++++++- script/gen_copilot_instructions.py | 21 ++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3be73a02a55b..771dd3d070fc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,7 +8,131 @@ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. -- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. +- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. + +## Pull Request template + +The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLATE.md`): + +```markdown + +## Breaking change + + + +## Proposed change + + + +## Type of change + + +- [ ] Dependency upgrade +- [ ] Bugfix (non-breaking change which fixes an issue) +- [ ] New integration (thank you!) +- [ ] New feature (which adds functionality to an existing integration) +- [ ] Deprecation (breaking change to happen in the future) +- [ ] Breaking change (fix/feature causing existing functionality to break) +- [ ] Code quality improvements to existing code or addition of tests + +## Additional information + + +- This PR fixes or closes issue: fixes # +- This PR is related to issue: +- Link to documentation pull request: +- Link to developer documentation pull request: +- Link to frontend pull request: + +## Checklist + + +- [ ] I understand the code I am submitting and can explain how it works. +- [ ] The code change is tested and works locally. +- [ ] Local tests pass. **Your PR cannot be merged unless tests pass** +- [ ] There is no commented out code in this PR. +- [ ] I have followed the [development checklist][dev-checklist] +- [ ] I have followed the [perfect PR recommendations][perfect-pr] +- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`) +- [ ] Tests have been added to verify that the new code works. +- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards. + +If user exposed functionality or configuration variables are added/changed: + +- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository] + +If the code communicates with devices, web services, or third-party tools: + +- [ ] The [manifest file][manifest-docs] has all fields filled out correctly. + Updated and included derived files by running: `python3 -m script.hassfest`. +- [ ] New or updated dependencies have been added to `requirements_all.txt`. + Updated by running `python3 -m script.gen_requirements_all`. +- [ ] For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description. + + + +To help with the load of incoming pull requests: + +- [ ] I have reviewed two other [open pull requests][prs] in this repository. + +[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure + + +[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/ +[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/ +[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/ +[docs-repository]: https://github.com/home-assistant/home-assistant.io +[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr +``` # GitHub Copilot & Claude Code Instructions diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index d1e91cb3f993..ae25f35779a6 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -17,6 +17,7 @@ INTEGRATION_SKILL_FILE = Path(".claude/skills/ha-integration-knowledge/SKILL.md" INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE = Path( ".github/instructions/integrations.instructions.md" ) +PR_TEMPLATE_FILE = Path(".github/PULL_REQUEST_TEMPLATE.md") COPILOT_SPECIFIC_INSTRUCTIONS = """ # Copilot code review instructions @@ -25,7 +26,15 @@ COPILOT_SPECIFIC_INSTRUCTIONS = """ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. -- Check that the PR description is complete and filled in according to the template at `.github/PULL_REQUEST_TEMPLATE.md`. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. +- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. + +## Pull Request template + +The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLATE.md`): + +```markdown +{pr_template} +``` """ INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """--- @@ -70,7 +79,15 @@ def generate_output() -> str: print(f"Error: {AGENTS_FILE} not found") sys.exit(1) - output_parts: list[str] = [GENERATED_MESSAGE, COPILOT_SPECIFIC_INSTRUCTIONS] + if not PR_TEMPLATE_FILE.exists(): + print(f"Error: {PR_TEMPLATE_FILE} not found") + sys.exit(1) + + copilot_instructions = COPILOT_SPECIFIC_INSTRUCTIONS.replace( + "{pr_template}", PR_TEMPLATE_FILE.read_text().strip() + ) + + output_parts: list[str] = [GENERATED_MESSAGE, copilot_instructions] # Add AGENTS.md content agents_content = AGENTS_FILE.read_text() From 7bda89f408ebfce1d8ae11d77a4fffe60a120c81 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 9 Jul 2026 22:04:14 +0200 Subject: [PATCH 407/707] Bump reolink_aio to 0.21.4 (#176156) --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/strings.json | 3 ++- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index f53a51c0c328..89d9d0e2ab9e 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.21.3"] + "requirements": ["reolink-aio==0.21.4"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 9982a3c21bc8..e88da2fbbaff 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -543,7 +543,8 @@ "autoadaptive": "Auto adaptive", "off": "[%key:common::state::off%]", "onatnight": "On at night", - "schedule": "Schedule" + "schedule": "Schedule", + "scheduleplus": "Schedule plus" } }, "hdr": { diff --git a/requirements_all.txt b/requirements_all.txt index 92a6d2d7c4c3..b7fa99ca8073 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2903,7 +2903,7 @@ renault-api==0.5.12 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.21.3 +reolink-aio==0.21.4 # homeassistant.components.radio_frequency rf-protocols==4.3.0 From 321bdd10d2169eb9abb424ea78dca1680b82f395 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jul 2026 23:39:04 +0200 Subject: [PATCH 408/707] Streamline security filter per-request work (#175561) Co-authored-by: Claude --- homeassistant/components/http/security_filter.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index 98df4a815d3e..a1a7e23423f0 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -55,11 +55,17 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" - path_with_query_string = f"{request.path}?{request.query_string}" + query_string = request.query_string + # Most requests (WebSocket/API traffic) have no query string; avoid the + # concat and only scan the path in that case. + if query_string: + path_with_query_string = f"{request.path}?{query_string}" + else: + path_with_query_string = request.path for unsafe_byte in UNSAFE_URL_BYTES: if unsafe_byte in path_with_query_string: - if unsafe_byte in request.query_string: + if unsafe_byte in query_string: _LOGGER.warning( "Filtered a request with unsafe byte query string: %s", request.raw_path, @@ -75,7 +81,7 @@ def setup_security_filter(app: Application) -> None: # Check the full path with query string first, if its # a hit, than check just the query string to give a more # specific warning. - if FILTERS.search(_recursive_unquote(request.query_string)): + if FILTERS.search(_recursive_unquote(query_string)): _LOGGER.warning( "Filtered a request with a potential harmful query string: %s", request.raw_path, From 925fd5e954aaf1a6c74ec187968e17fbd7f5c419 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:16:01 +0200 Subject: [PATCH 409/707] Update coverage to 7.15.0 (#176175) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index c5156f0088bd..756d98098354 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,7 +10,7 @@ # ast-serialize is an internal mypy dependency ast-serialize==0.6.0 astroid==4.0.4 -coverage==7.14.3 +coverage==7.15.0 freezegun==1.5.5 # librt is an internal mypy dependency librt==0.12.0 From ed0d5cfd8d8b451f80598ed3f941bb0ceb9cf119 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Jul 2026 07:08:57 +0200 Subject: [PATCH 410/707] Move script tool-alias logic into the script integration (#176114) Co-authored-by: Claude --- homeassistant/components/script/llm.py | 8 ++++++++ homeassistant/helpers/llm.py | 16 ---------------- tests/components/script/test_llm.py | 14 +++++++++++++- tests/helpers/test_llm.py | 21 +++++---------------- 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/script/llm.py b/homeassistant/components/script/llm.py index e739d979a7c6..a669b6709110 100644 --- a/homeassistant/components/script/llm.py +++ b/homeassistant/components/script/llm.py @@ -34,6 +34,14 @@ class ScriptTool(ActionTool): if self.name[0].isdigit(): self.name = "_" + self.name + if entity_entry and ( + aliases := er.async_get_entity_aliases(hass, entity_entry) + ): + alias_text = "Aliases: " + str(sorted(aliases)) + self.description = ( + f"{self.description}. {alias_text}" if self.description else alias_text + ) + @callback def async_get_tools( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index d3f10a65a573..efc050a2c257 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -9,7 +9,6 @@ import slugify as unicode_slug import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert -from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.const import ( ATTR_DOMAIN, ATTR_SERVICE, @@ -26,7 +25,6 @@ from . import ( area_registry as ar, config_validation as cv, device_registry as dr, - entity_registry as er, floor_registry as fr, intent, selector, @@ -603,20 +601,6 @@ def _get_cached_action_parameters( parameters = vol.Schema(schema) - if domain == SCRIPT_DOMAIN: - entity_registry = er.async_get(hass) - if ( - entity_id := entity_registry.async_get_entity_id(domain, domain, action) - ) is not None and ( - entity_entry := entity_registry.async_get(entity_id) - ) is not None: - aliases = er.async_get_entity_aliases(hass, entity_entry) - if aliases: - if description: - description = description + ". Aliases: " + str(sorted(aliases)) - else: - description = "Aliases: " + str(sorted(aliases)) - parameters_cache.setdefault(domain, {})[action] = (description, parameters) return description, parameters diff --git a/tests/components/script/test_llm.py b/tests/components/script/test_llm.py index 7d00d29aa7cf..9295d4c05553 100644 --- a/tests/components/script/test_llm.py +++ b/tests/components/script/test_llm.py @@ -6,7 +6,7 @@ from homeassistant.components import llm as llm_component from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.script import llm as script_llm from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import llm +from homeassistant.helpers import entity_registry as er, llm from homeassistant.setup import async_setup_component ENTITY_ID = "script.test_script" @@ -92,3 +92,15 @@ async def test_script_tool_name_not_started_with_digit(hass: HomeAssistant) -> N async_expose_entity(hass, "conversation", "script.123456", True) result = await llm_component.async_get_tools(hass, _llm_context(), "assist") assert "_123456" in [tool.name for tool in result.tools] + + +async def test_script_tool_description_includes_aliases( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the script tool description is extended with the entity aliases.""" + entity_registry.async_update_entity(ENTITY_ID, aliases=["barkeep", "pour a drink"]) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tool = next(tool for tool in result.tools if tool.name == "test_script") + assert tool.description == ( + "This is a test script. Aliases: ['barkeep', 'pour a drink']" + ) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index d2cf638da457..805dd3239c90 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -861,15 +861,10 @@ async def test_action_tool( } assert tool.parameters.schema == schema + # The parameter cache stores the base description; ScriptTool appends aliases. assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { - "test_script": ( - "This is a test script. Aliases: ['script alias', 'script name']", - vol.Schema(schema), - ), - "script_with_no_fields": ( - "This is another test script. Aliases: ['test script 2']", - vol.Schema({}), - ), + "test_script": ("This is a test script", vol.Schema(schema)), + "script_with_no_fields": ("This is another test script", vol.Schema({})), } # Test script with response @@ -978,14 +973,8 @@ async def test_action_tool( assert tool.parameters.schema == schema assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { - "test_script": ( - "This is a new test script. Aliases: ['script alias', 'script name']", - vol.Schema(schema), - ), - "script_with_no_fields": ( - "This is another test script. Aliases: ['test script 2']", - vol.Schema({}), - ), + "test_script": ("This is a new test script", vol.Schema(schema)), + "script_with_no_fields": ("This is another test script", vol.Schema({})), } From 6ae06706d208269c9a4e90a3555d8d77b1d2eccf Mon Sep 17 00:00:00 2001 From: David Date: Thu, 9 Jul 2026 22:11:52 -0700 Subject: [PATCH 411/707] Bump pylutron-caseta to 0.29.0 (#176181) --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index d53187425161..f6bba1ab2a34 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -10,7 +10,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.28.0"], + "requirements": ["pylutron-caseta==0.29.0"], "zeroconf": [ { "properties": { diff --git a/requirements_all.txt b/requirements_all.txt index b7fa99ca8073..0ed444fd5fdc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2338,7 +2338,7 @@ pylitejet==0.6.3 pylitterbot==2025.5.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.28.0 +pylutron-caseta==0.29.0 # homeassistant.components.lutron pylutron==0.4.2 From 3cea3f6575657f6fd5ae9344514d924b7550f803 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:39:15 +0200 Subject: [PATCH 412/707] Flag hass.data.setdefault/get(DOMAIN) in runtime_data pylint checker (#176084) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/caldav/api.py | 1 + .../components/google_mail/__init__.py | 1 + .../components/growatt_server/__init__.py | 1 + homeassistant/components/lookin/__init__.py | 1 + .../components/netgear_lte/__init__.py | 1 + homeassistant/components/nextbus/__init__.py | 2 + .../components/nmap_tracker/__init__.py | 1 + homeassistant/components/nmbs/__init__.py | 1 + homeassistant/components/plex/__init__.py | 1 + .../private_ble_device/coordinator.py | 1 + homeassistant/components/pushover/__init__.py | 1 + .../components/remote_calendar/__init__.py | 1 + homeassistant/components/rfxtrx/__init__.py | 1 + homeassistant/components/rfxtrx/services.py | 1 + .../components/ruuvi_gateway/__init__.py | 1 + .../components/ruuvitag_ble/__init__.py | 1 + .../components/sensorpro/__init__.py | 1 + homeassistant/components/steamist/__init__.py | 1 + .../components/thermopro/__init__.py | 1 + homeassistant/components/traccar/__init__.py | 1 + .../checkers/runtime_data.py | 92 ++++++++++++++----- tests/pylint/test_runtime_data.py | 50 ++++++++++ 22 files changed, 138 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index 2f91d76aba9a..b64b7fb8e734 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -1,4 +1,5 @@ """Library for working with CalDAV api.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 3700e0fb890d..f1d46178ccc8 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -1,4 +1,5 @@ """Support for Google Mail.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 819f5c8edbd0..4435217a04ad 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -23,6 +23,7 @@ Error handling pattern for reauth: → raise ConfigEntryAuthFailed - All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator) """ +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections.abc import Mapping import datetime diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index bd5950b46beb..73ecf37efd41 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -1,4 +1,5 @@ """The lookin integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index a2c6338f21cf..af4a3a2fa1cd 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,4 +1,5 @@ """Support for Netgear LTE modems.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from typing import Any diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index b21853618cef..5d0029f4448e 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -17,6 +17,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_stop = entry.data[CONF_STOP] coordinator_key = f"{entry_agency}-{entry_stop}" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: NextBusDataUpdateCoordinator | None = hass.data.setdefault( DOMAIN, {} ).get( diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index ad2d7aa3705b..590783d61a4d 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -1,4 +1,5 @@ """The Nmap Tracker integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from dataclasses import dataclass diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 97c489aeb14f..0d47a125e2b4 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -1,4 +1,5 @@ """The NMBS component.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 0e4e772485a8..cb8e3e3e3c4d 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -1,4 +1,5 @@ """Support to embed Plex.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from functools import partial import logging diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py index 95d20d7ff2b6..6dd60337e26d 100644 --- a/homeassistant/components/private_ble_device/coordinator.py +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -1,4 +1,5 @@ """Central manager for tracking devices with random but resolvable MAC addresses.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections.abc import Callable import logging diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index 16e850f5e889..75187a83e090 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -1,4 +1,5 @@ """The pushover component.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from pushover_complete import BadAPIRequestError, PushoverAPI from requests.exceptions import RequestException diff --git a/homeassistant/components/remote_calendar/__init__.py b/homeassistant/components/remote_calendar/__init__.py index 910eeae8268c..8137fb49eb4b 100644 --- a/homeassistant/components/remote_calendar/__init__.py +++ b/homeassistant/components/remote_calendar/__init__.py @@ -1,4 +1,5 @@ """The Remote Calendar integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index e405aadfe06e..a67dec515aeb 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,4 +1,5 @@ """Support for RFXtrx devices.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import binascii from collections.abc import Callable, Mapping diff --git a/homeassistant/components/rfxtrx/services.py b/homeassistant/components/rfxtrx/services.py index c1981dbc1224..896b6b8ffd81 100644 --- a/homeassistant/components/rfxtrx/services.py +++ b/homeassistant/components/rfxtrx/services.py @@ -1,4 +1,5 @@ """Support for RFXtrx services.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from typing import Any diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py index 94ebf6fbcf61..c5af0db31540 100644 --- a/homeassistant/components/ruuvi_gateway/__init__.py +++ b/homeassistant/components/ruuvi_gateway/__init__.py @@ -1,4 +1,5 @@ """The Ruuvi Gateway integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/ruuvitag_ble/__init__.py b/homeassistant/components/ruuvitag_ble/__init__.py index 01634bfce88c..65d096ca1b20 100644 --- a/homeassistant/components/ruuvitag_ble/__init__.py +++ b/homeassistant/components/ruuvitag_ble/__init__.py @@ -1,4 +1,5 @@ """The ruuvitag_ble integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/sensorpro/__init__.py b/homeassistant/components/sensorpro/__init__.py index 167c5d167462..4e8c131d698d 100644 --- a/homeassistant/components/sensorpro/__init__.py +++ b/homeassistant/components/sensorpro/__init__.py @@ -1,4 +1,5 @@ """The SensorPro integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index 92904ec5fd1f..5b96db9e2a3f 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -1,4 +1,5 @@ """The Steamist integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import timedelta from typing import Any diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py index 9a7467bdcd55..dc56b848e72e 100644 --- a/homeassistant/components/thermopro/__init__.py +++ b/homeassistant/components/thermopro/__init__.py @@ -1,4 +1,5 @@ """The ThermoPro Bluetooth integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from functools import partial import logging diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index e8c151179ce5..9126e0d9db5d 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1,4 +1,5 @@ """Support for Traccar Client.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from http import HTTPStatus from json import JSONDecodeError diff --git a/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py b/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py index 3d4f0efea9e3..0c29cf5ef95a 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py +++ b/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py @@ -4,6 +4,10 @@ New integrations should store per-entry data on ``entry.runtime_data`` (typed via a ``type`` alias) rather than the legacy ``hass.data[DOMAIN][entry.entry_id]`` dictionary pattern. The ``runtime_data`` approach is type-safe, automatically cleaned up on unload, and is the current Home Assistant core standard. + +Both the subscript form (``hass.data[DOMAIN]``) and the equivalent method-call +forms (``hass.data.setdefault(DOMAIN, ...)`` and ``hass.data.get(DOMAIN)``) are +flagged. """ from astroid import nodes @@ -54,22 +58,7 @@ class HassEnforceRuntimeDataChecker(BaseChecker): if not _is_hass_data_domain_access(node): return - parsed = parse_module(node.root().name) - if parsed is None: - return - - current_module = parsed.module or "" - if current_module in _SKIP_MODULES: - return - - # Only flag integrations that have a config flow (and thus can use - # entry.runtime_data). YAML-only integrations legitimately need - # hass.data[DOMAIN]. - if not has_config_flow(parsed.domain, node.root()): - return - - func = enclosing_function(node) - if func and func.name in _SKIP_FUNCTIONS: + if not self._should_flag(node): return # Don't flag deletion: del hass.data[DOMAIN] or hass.data[DOMAIN].pop(...) @@ -81,25 +70,78 @@ class HassEnforceRuntimeDataChecker(BaseChecker): self.add_message("home-assistant-use-runtime-data", node=node) + def visit_call(self, node: nodes.Call) -> None: + """Check for hass.data.setdefault(DOMAIN, ...) and hass.data.get(DOMAIN).""" + if not _is_hass_data_domain_call(node): + return + + if not self._should_flag(node): + return + + self.add_message("home-assistant-use-runtime-data", node=node) + + def _should_flag(self, node: nodes.NodeNG) -> bool: + """Return True if node is in an integration that should use runtime_data.""" + parsed = parse_module(node.root().name) + if parsed is None: + return False + + current_module = parsed.module or "" + if current_module in _SKIP_MODULES: + return False + + # Only flag integrations that have a config flow (and thus can use + # entry.runtime_data). YAML-only integrations legitimately need + # hass.data[DOMAIN]. + if not has_config_flow(parsed.domain, node.root()): + return False + + func = enclosing_function(node) + return not (func and func.name in _SKIP_FUNCTIONS) + + +def _is_hass_data(node: nodes.NodeNG) -> bool: + """Return True if node is hass.data or self.hass.data.""" + match node: + case nodes.Attribute( + expr=( + nodes.Name(name="hass") + | nodes.Attribute(expr=nodes.Name(name="self"), attrname="hass") + ), + attrname="data", + ): + return True + case _: + return False + def _is_hass_data_domain_access(node: nodes.Subscript) -> bool: """Return True if node is hass.data[DOMAIN] or self.hass.data[DOMAIN].""" match node: - case nodes.Subscript( - value=nodes.Attribute( - expr=( - nodes.Name(name="hass") - | nodes.Attribute(expr=nodes.Name(name="self"), attrname="hass") - ), - attrname="data", - ), - slice=nodes.Name(name="DOMAIN"), + case nodes.Subscript(value=value, slice=nodes.Name(name="DOMAIN")) if ( + _is_hass_data(value) ): return True case _: return False +def _is_hass_data_domain_call(node: nodes.Call) -> bool: + """Return True for hass.data.setdefault(DOMAIN, ...) or hass.data.get(DOMAIN). + + These read/write DOMAIN data just like the subscript form. Deletion helpers + such as ``hass.data.pop(DOMAIN)`` are intentionally not matched. + """ + match node: + case nodes.Call( + func=nodes.Attribute(expr=value, attrname="setdefault" | "get"), + args=[nodes.Name(name="DOMAIN"), *_], + ) if _is_hass_data(value): + return True + case _: + return False + + def register(linter: PyLinter) -> None: """Register the checker.""" linter.register_checker(HassEnforceRuntimeDataChecker(linter)) diff --git a/tests/pylint/test_runtime_data.py b/tests/pylint/test_runtime_data.py index 7230b596d355..e62efe9247f7 100644 --- a/tests/pylint/test_runtime_data.py +++ b/tests/pylint/test_runtime_data.py @@ -93,6 +93,28 @@ from . import assert_no_messages, walk_checker "homeassistant.components.test", id="pop_from_hass_data", ), + pytest.param( + """ + hass.data.pop(DOMAIN) + """, + "homeassistant.components.test", + id="pop_hass_data_domain", + ), + pytest.param( + """ + if DOMAIN in hass.data: + pass + """, + "homeassistant.components.test", + id="domain_in_hass_data", + ), + pytest.param( + """ + hass.data.setdefault(OTHER_KEY, {}) + """, + "homeassistant.components.test", + id="setdefault_non_domain_key", + ), ], ) def test_enforce_runtime_data( @@ -147,6 +169,34 @@ def test_enforce_runtime_data( "homeassistant.components.test", id="async_setup_entry", ), + pytest.param( + """ + hass.data.setdefault(DOMAIN, {}) + """, + "homeassistant.components.test", + id="setdefault_hass_data_domain", + ), + pytest.param( + """ + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = some_value + """, + "homeassistant.components.test", + id="setdefault_hass_data_domain_nested", + ), + pytest.param( + """ + value = hass.data.get(DOMAIN) + """, + "homeassistant.components.test.sensor", + id="get_hass_data_domain", + ), + pytest.param( + """ + value = self.hass.data.setdefault(DOMAIN, {}) + """, + "homeassistant.components.test.coordinator", + id="self_setdefault_hass_data_domain", + ), ], ) def test_enforce_runtime_data_bad( From ed66ceb9fa901d9012f8f1a606025e11b813f299 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:53:57 +0200 Subject: [PATCH 413/707] Bump uiprotect to 15.6.0 (#176182) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ebce3422e703..e6b3838cc0a3 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.5.0"] + "requirements": ["uiprotect==15.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0ed444fd5fdc..3751cedc8b56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3246,7 +3246,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.5.0 +uiprotect==15.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From e325be6f872deab6876146a24d22d4c0a08c7d75 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 10 Jul 2026 16:03:14 +1000 Subject: [PATCH 414/707] Bump tesla-fleet-api to 1.6.0 (#176180) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index cc1325780b1a..a4b7369ef7e0 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.5.4"] + "requirements": ["tesla-fleet-api==1.6.0"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 269c9ceef2cc..8179abd7be77 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.5.4", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.6.0", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 2be7f21d458e..026dd8b49dd7 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.5.4"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3751cedc8b56..1f566f7ff0cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3156,7 +3156,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.5.4 +tesla-fleet-api==1.6.0 # homeassistant.components.powerwall tesla-powerwall==0.5.3 From b9da56c76e84ce268bb8688c6d093537389013d6 Mon Sep 17 00:00:00 2001 From: Manu Date: Fri, 10 Jul 2026 08:03:55 +0200 Subject: [PATCH 415/707] Raise `HomeAssistantError` when retries are exhausted in STMP legacy notify action (#176165) --- homeassistant/components/smtp/notify.py | 24 ++++++++++++----- tests/components/smtp/test_notify.py | 34 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 4f887ae3b448..325f121575e3 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -315,25 +315,37 @@ class MailNotificationService(SmtpClient, BaseNotificationService): def _send_email(self, msg: MIMEMultipart | MIMEText, recipients: list[str]) -> None: """Send the message.""" mail = self.connect() - for _ in range(self.tries): + for attempt in range(self.tries): try: mail.sendmail(self._sender, recipients, msg.as_string()) break - except SMTPServerDisconnected: + except SMTPServerDisconnected as e: + with suppress(SMTPException): + mail.quit() + if attempt == self.tries - 1: + _LOGGER.debug("Full exception:", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_mail_connection_error", + ) from e _LOGGER.warning( "SMTPServerDisconnected sending mail: retrying connection", exc_info=_LOGGER.isEnabledFor(logging.DEBUG), ) + mail = self.connect() + except SMTPException as e: with suppress(SMTPException): mail.quit() - mail = self.connect() - except SMTPException: + if attempt == self.tries - 1: + _LOGGER.debug("Full exception:", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_mail_connection_error", + ) from e _LOGGER.warning( "SMTPException sending mail: retrying connection", exc_info=_LOGGER.isEnabledFor(logging.DEBUG), ) - with suppress(SMTPException): - mail.quit() mail = self.connect() with suppress(SMTPException): mail.quit() diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 3ebebfed260a..510013aa53a4 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -4,6 +4,7 @@ from pathlib import Path import re from smtplib import ( SMTPAuthenticationError, + SMTPException, SMTPHeloError, SMTPSenderRefused, SMTPServerDisconnected, @@ -16,6 +17,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.notify import ( ATTR_MESSAGE, + ATTR_TARGET, DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, ) @@ -360,3 +362,35 @@ async def test_notify_retry_on_disconnect_with_broken_quit( ) assert smtp.sendmail.call_count == 2 + + +@pytest.mark.parametrize("exception", [SMTPServerDisconnected, SMTPException]) +async def test_legacy_notify_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + smtp: MagicMock, + exception: Exception, +) -> None: + """Test legacy notify action raises when retries are exhausted.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + smtp.sendmail.side_effect = exception + + with pytest.raises(HomeAssistantError) as e: + await hass.services.async_call( + NOTIFY_DOMAIN, + "home_assistant", + { + ATTR_TARGET: ["recipient@example.com"], + ATTR_MESSAGE: "Hello World", + }, + blocking=True, + ) + + assert e.value.translation_key == "send_mail_connection_error" + assert smtp.sendmail.call_count == 2 From 7399aafb804a8ab795d46c4d1d70ceb91045d45c Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:04:21 +0800 Subject: [PATCH 416/707] Switchbot Cloud: Enable Webhook for Lock (#176176) --- .../components/switchbot_cloud/__init__.py | 16 -------------- .../components/switchbot_cloud/const.py | 21 +++++++++++++++++++ .../components/switchbot_cloud/lock.py | 8 ++++++- tests/components/switchbot_cloud/test_lock.py | 2 -- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 81fe526a2938..421a867da3be 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -186,22 +186,6 @@ async def make_device_data( ) devices_data.vacuums.append((device, coordinator)) - if isinstance(device, Device) and device.device_type in [ - "Smart Lock Lite", - "Smart Lock Pro", - "Smart Lock Vision", - "Smart Lock Vision Pro", - "Smart Lock Pro Wifi", - "Lock Vision", - "Lock Vision Pro", - ]: - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id - ) - devices_data.locks.append((device, coordinator)) - devices_data.sensors.append((device, coordinator)) - devices_data.binary_sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "Bot": coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id, True diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 953f303cc7f5..d496470a26df 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -130,6 +130,27 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = { "Smart Lock Ultra": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) ), + "Smart Lock Vision": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Vision Pro": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Lock Vision": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Lock Vision Pro": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Lite": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Pro": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Pro Wifi": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), "MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "AI Art Frame": SwitchbotCloudDeviceConfig( True, entity_config=(Platform.SENSOR, Platform.BUTTON, Platform.IMAGE) diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index e2e93f0518cc..c17ac1128b45 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -45,7 +45,13 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: self._attr_is_locked = coord_data["lockState"].lower() == "locked" - if self.__model != "Smart Lock Lite": + if self.__model not in [ + "Smart Lock Lite", + "Smart Lock Vision", + "Smart Lock Vision Pro", + "Lock Vision", + "Lock Vision Pro", + ]: self._attr_supported_features = LockEntityFeature.OPEN @override diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index 91ddb124dc03..c4d7bf6d090d 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -73,8 +73,6 @@ async def test_lock( ("Smart Lock", 0), ("Smart Lock Pro", 1), ("Smart Lock Ultra", 2), - ("Lock Vision", 3), - ("Lock Vision Pro", 4), ("Smart Lock Pro Wifi", 5), ], ) From 269ddc67aa1d0157f7abea9bb0d36a36f9d5b063 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:08:32 +0200 Subject: [PATCH 417/707] Use generic opening device class and proper icons for gas station status in tankerkoenig (#176145) --- homeassistant/components/tankerkoenig/binary_sensor.py | 2 +- homeassistant/components/tankerkoenig/icons.json | 9 +++++++++ .../tankerkoenig/snapshots/test_binary_sensor.ambr | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 344778e7ba42..9d6aba087081 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -42,7 +42,7 @@ async def async_setup_entry( class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity): """Shows if a station is open or closed.""" - _attr_device_class = BinarySensorDeviceClass.DOOR + _attr_device_class = BinarySensorDeviceClass.OPENING _attr_translation_key = "status" def __init__( diff --git a/homeassistant/components/tankerkoenig/icons.json b/homeassistant/components/tankerkoenig/icons.json index 05b9d3bcbca6..47f03fd7bca5 100644 --- a/homeassistant/components/tankerkoenig/icons.json +++ b/homeassistant/components/tankerkoenig/icons.json @@ -1,5 +1,14 @@ { "entity": { + "binary_sensor": { + "status": { + "default": "mdi:store", + "state": { + "off": "mdi:store-off", + "on": "mdi:store" + } + } + }, "sensor": { "diesel": { "default": "mdi:gas-station" diff --git a/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr b/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr index bce05a91749e..e4657b6f4e37 100644 --- a/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr +++ b/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_binary_sensor ReadOnlyDict({ - : 'door', + : 'opening', : 'Station Somewhere Street 1 Status', 'latitude': 51.1, 'longitude': 13.1, From 603acf2a81bfb96045f95277a076c803d3dcda12 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:13:18 -0400 Subject: [PATCH 418/707] Sonos - avoid blocking call to get uid (#176109) --- homeassistant/components/sonos/__init__.py | 17 +++++- tests/components/sonos/test_init.py | 69 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 21fb5a88b544..1678eac5e599 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -496,9 +496,22 @@ class SonosDiscoveryManager: None, ) if not known_speaker: + try: + uid = await self.hass.async_add_executor_job(getattr, soco, "uid") + except HTTPError as err: + await self._process_http_connection_error(err, ip_addr) + continue + except ( + OSError, + SoCoException, + Timeout, + TimeoutError, + ) as ex: + _LOGGER.warning("Could not get Sonos uid from %s: %s", ip_addr, ex) + continue try: await self._async_handle_discovery_message( - soco.uid, + uid, ip_addr, "manual zone scan", ) @@ -515,7 +528,7 @@ class SonosDiscoveryManager: # Only send the message if the ping was successful. async_dispatcher_send( self.hass, - f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}", + f"{SONOS_SPEAKER_ACTIVITY}-{known_speaker.uid}", "manual zone scan", ) except SonosUpdateError: diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 0c655de07492..f718428acab6 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus +from itertools import chain, repeat import logging from unittest.mock import Mock, PropertyMock, patch @@ -252,6 +253,15 @@ class _MockSoCoVisibleZones(MockSoCo): return self.vz_return +class _MockSoCoUidError(MockSoCo): + """Mock SoCo used for uid property error tests.""" + + @property + def visible_zones(self): + """Return no additional zones without touching uid lookup.""" + return set() + + async def _setup_hass(hass: HomeAssistant): await async_setup_component( hass, @@ -294,6 +304,65 @@ async def test_async_poll_manual_hosts_1( await hass.async_block_till_done(wait_background_tasks=True) +async def test_async_poll_manual_hosts_uid_oserror( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test uid lookup OSError skips host and logs warning.""" + soco_1 = soco_factory.cache_mock(_MockSoCoUidError(), "10.10.10.1", "Living Room") + soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + uid = soco_1.uid + + with ( + caplog.at_level(logging.WARNING), + patch.object( + type(soco_1), + "uid", + new_callable=PropertyMock, + create=True, + side_effect=chain([uid], repeat(OSError("uid unavailable"))), + ), + ): + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert f"Could not get Sonos uid from {soco_1.ip_address}" in caplog.text + + await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_async_poll_manual_hosts_uid_http_error( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test uid lookup HTTPError skips host.""" + resp = Response() + resp.status_code = HTTPStatus.FORBIDDEN + http_error = HTTPError(response=resp) + + soco_1 = soco_factory.cache_mock(_MockSoCoUidError(), "10.10.10.1", "Living Room") + soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + uid = soco_1.uid + + with patch.object( + type(soco_1), + "uid", + new_callable=PropertyMock, + create=True, + side_effect=chain([uid], repeat(http_error)), + ): + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + + await hass.async_block_till_done(wait_background_tasks=True) + + async def test_async_poll_manual_hosts_2( hass: HomeAssistant, soco_factory: SoCoMockFactory, From e0cf9c6415872ae00f89c5e8ed23ac3fac309a4f Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:28:06 +0200 Subject: [PATCH 419/707] Migrate UniFi Protect sense motion sensitivity to the public API (#176107) --- .../components/unifiprotect/number.py | 4 +- tests/components/unifiprotect/test_number.py | 91 ++++++++++++++++++- tests/components/unifiprotect/utils.py | 8 +- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 5966454c1559..276cb61ec3e5 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -201,8 +201,8 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=100, ufp_step=1, - ufp_value="motion_settings.sensitivity", - ufp_set_method="set_motion_sensitivity", + ufp_public_value="motion_settings.sensitivity", + ufp_set_method="set_motion_sensitivity_public", ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.WRITE, ), diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 20a77ec0930c..56b595871ae1 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -4,12 +4,21 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import Camera, Chime, DeviceState, IRLEDMode, Light, RingSetting +from uiprotect.data import ( + Camera, + Chime, + DeviceState, + IRLEDMode, + Light, + RingSetting, + Sensor, +) from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.number import ( CAMERA_NUMBERS, LIGHT_NUMBERS, + SENSE_NUMBERS, ProtectNumberEntityDescription, ) from homeassistant.const import ( @@ -30,9 +39,11 @@ from .utils import ( ids_from_device_description, init_entry, make_public_light, + make_public_sensor, public_device_ws_message, remove_entities, setup_public_light, + setup_public_sensor, ) @@ -309,6 +320,84 @@ async def test_number_camera_simple( mock_method.assert_called_once_with(1.0) +async def test_number_sense_sensitivity_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """Motion sensitivity reads from the public object and refreshes on a WS update.""" + + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + + # A public value the private fixture (100) would not produce proves the source. + public = make_public_sensor(sensor_all, motion_sensitivity=42) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "42" + + +async def test_number_sense_sensitivity_set( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """Setting the motion sensitivity calls the public API setter.""" + + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + + with patch_ufp_method( + sensor_all, "set_motion_sensitivity_public", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id, "value": 60.0}, + blocking=True, + ) + + mock_method.assert_called_once_with(60.0) + + +async def test_number_sense_sensitivity_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The migrated motion sensitivity number is unavailable without a public object.""" + + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_number_sense_sensitivity_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """Motion sensitivity availability follows the public object's connection state.""" + + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_sensor(sensor_all, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + def _setup_chime_with_doorbell( chime: Chime, doorbell: Camera, volume: int = 50 ) -> None: diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index f3de49d75d2e..6560f66b04b4 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -237,6 +237,7 @@ def make_public_sensor( state: DeviceState | None = None, is_motion_detected: bool | None = None, motion_enabled: bool | None = None, + motion_sensitivity: int | None = None, mount_type: MountType | None = None, is_opened: bool | None = None, is_leak_detected: bool | None = None, @@ -295,7 +296,12 @@ def make_public_sensor( sensor.motion_settings.is_enabled if motion_enabled is None else motion_enabled - ) + ), + sensitivity=( + sensor.motion_settings.sensitivity + if motion_sensitivity is None + else motion_sensitivity + ), ) public.wireless_connection_state = PublicWirelessConnectionState( battery_status=PublicWirelessBatteryStatus( From e9c3e30f1a3a473eeb65016d276983a2a8206c89 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Jul 2026 08:33:13 +0200 Subject: [PATCH 420/707] Simplify todo LLM tools platform (#176183) Co-authored-by: Claude --- homeassistant/components/todo/llm.py | 12 ++++-------- tests/components/todo/test_llm.py | 12 +++++++----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py index ddfe261179c0..189c03040fb2 100644 --- a/homeassistant/components/todo/llm.py +++ b/homeassistant/components/todo/llm.py @@ -76,11 +76,10 @@ class TodoGetItemsTool(Tool): return {"success": False, "error": "To-do list not found"} entity_id = result.states[0].entity_id service_data: dict[str, Any] = {"entity_id": entity_id} - if status := data.get("status"): - if status == "all": - service_data["status"] = ["needs_action", "completed"] - else: - service_data["status"] = [status] + status = data["status"] + # "all" means no status filter, which returns every item. + if status != "all": + service_data["status"] = status service_result = await hass.services.async_call( DOMAIN, TodoServices.GET_ITEMS, @@ -103,9 +102,6 @@ def async_get_tools( if api_id != LLM_API_ASSIST: return None - if not llm_context.assistant: - return None - entity_registry = er.async_get(hass) names: list[str] = [] for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py index c409e0e0288b..e0a373634cf6 100644 --- a/tests/components/todo/test_llm.py +++ b/tests/components/todo/test_llm.py @@ -87,14 +87,16 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("status", "expected"), + ("status", "expected_data"), [ - ("all", ["needs_action", "completed"]), - ("completed", ["completed"]), + # "all" is sent without a status filter, so the service returns every item. + ("all", {"entity_id": [ENTITY_ID]}), + ("needs_action", {"entity_id": [ENTITY_ID], "status": ["needs_action"]}), + ("completed", {"entity_id": [ENTITY_ID], "status": ["completed"]}), ], ) async def test_todo_get_items_status_filter( - hass: HomeAssistant, status: str, expected: list[str] + hass: HomeAssistant, status: str, expected_data: dict[str, list[str]] ) -> None: """Test the status filter is translated into the service call.""" llm_context = _llm_context() @@ -115,7 +117,7 @@ async def test_todo_get_items_status_filter( ), llm_context, ) - assert calls[0].data == {"entity_id": [ENTITY_ID], "status": expected} + assert calls[0].data == expected_data async def test_todo_list_intents_exposed(hass: HomeAssistant) -> None: From c606de15644f56a4b6ffaef2ec7eff2d2938e29d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:33:36 +0200 Subject: [PATCH 421/707] Bump docker/login-action from 4.3.0 to 4.4.0 (#176184) Signed-off-by: dependabot[bot] --- .github/workflows/builder.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d5448e651147..51f5a5f72da4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,13 +342,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -521,7 +521,7 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 36d5e3e80aea0e2c689cd7f5add576510432f034 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 10 Jul 2026 08:50:55 +0200 Subject: [PATCH 422/707] Fix rest_command digest auth with templated hosts (#176155) --- .../components/rest_command/__init__.py | 10 ++++++---- tests/components/rest_command/test_init.py | 19 +++++++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 92846c85f094..7d6627d01ec4 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -117,12 +117,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: skip_url_encoding = command_config[CONF_SKIP_URL_ENCODING] auth = None - digest_middleware = None + digest_auth: tuple[str, str] | None = None if CONF_USERNAME in command_config: username = command_config[CONF_USERNAME] password = command_config.get(CONF_PASSWORD, "") if command_config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - digest_middleware = aiohttp.DigestAuthMiddleware(username, password) + digest_auth = (username, password) else: auth = aiohttp.BasicAuth(username, password=password) @@ -177,8 +177,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Add authentication if auth is not None: request_kwargs["auth"] = auth - elif digest_middleware is not None: - request_kwargs["middlewares"] = (digest_middleware,) + elif digest_auth is not None: + request_kwargs["middlewares"] = ( + aiohttp.DigestAuthMiddleware(*digest_auth), + ) async with getattr(websession, method)( URL(request_url, encoded=skip_url_encoding), diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 2220755c8df8..8d3c1ca34993 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -126,10 +126,10 @@ async def test_rest_command_auth( assert len(aioclient_mock.mock_calls) == 1 +@pytest.mark.usefixtures("aioclient_mock") async def test_rest_command_digest_auth( hass: HomeAssistant, setup_component: ComponentSetup, - aioclient_mock: AiohttpClientMocker, ) -> None: """Call a rest command with HTTP digest authentication.""" config = { @@ -144,11 +144,9 @@ async def test_rest_command_digest_auth( await setup_component(config) - # Mock the digest auth behavior - the request will be called - # with DigestAuthMiddleware with patch("aiohttp.ClientSession.get") as mock_get: - async def async_iter_chunks(self, chunk_size): + async def async_iter_chunks(self, chunk_size: int): yield b"success" mock_response = type( @@ -166,14 +164,15 @@ async def test_rest_command_digest_auth( )() mock_get.return_value.__aenter__.return_value = mock_response + await hass.services.async_call(DOMAIN, "digest_auth_test", {}, blocking=True) await hass.services.async_call(DOMAIN, "digest_auth_test", {}, blocking=True) - # Verify that the request was made with DigestAuthMiddleware - assert mock_get.called - call_kwargs = mock_get.call_args[1] - assert "middlewares" in call_kwargs - assert len(call_kwargs["middlewares"]) == 1 - assert isinstance(call_kwargs["middlewares"][0], aiohttp.DigestAuthMiddleware) + assert len(mock_get.call_args_list) == 2 + first_middleware = mock_get.call_args_list[0].kwargs["middlewares"][0] + second_middleware = mock_get.call_args_list[1].kwargs["middlewares"][0] + assert isinstance(first_middleware, aiohttp.DigestAuthMiddleware) + assert isinstance(second_middleware, aiohttp.DigestAuthMiddleware) + assert first_middleware is not second_middleware async def test_rest_command_form_data( From 61de8347564f8f2c9a0f70d90cf93e6f86cc3a7a Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 10 Jul 2026 08:51:44 +0200 Subject: [PATCH 423/707] Bump zha-quirks to 2.1.1 (#176173) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d6d6fa08516f..180a95286afe 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==2.0.0", "zha-quirks==2.1.0"], + "requirements": ["zha==2.0.0", "zha-quirks==2.1.1"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 1f566f7ff0cf..43ee67ce7c5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3454,7 +3454,7 @@ zeroconf==0.150.0 zeversolar==0.3.2 # homeassistant.components.zha -zha-quirks==2.1.0 +zha-quirks==2.1.1 # homeassistant.components.zha zha==2.0.0 From 2989e6bcdf639489d2073276603a3c38d49eee21 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:55:57 +0200 Subject: [PATCH 424/707] Update Pillow to 12.3.0 (#176179) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index bee7cb77b297..1694a024a833 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==12.2.0"] + "requirements": ["pydoods==1.0.2", "Pillow==12.3.0"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 33463d54dbd0..ec69e74bc215 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==17.0.1", "Pillow==12.2.0"] + "requirements": ["av==17.0.1", "Pillow==12.3.0"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 8379e224a0a0..ad8824f23300 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==12.2.0"] + "requirements": ["Pillow==12.3.0"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 8755819e9505..591d58e9c5c3 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==12.2.0", "aiofiles==24.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==12.3.0", "aiofiles==24.1.0"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 4c89754f04f1..0bc3f6608cf3 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0"] + "requirements": ["Pillow==12.3.0"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 7d3e750f441e..f2b95947f546 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0", "pyzbar==0.1.9"] + "requirements": ["Pillow==12.3.0", "pyzbar==0.1.9"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 75906382a1b4..dc46f2c8ff31 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0"] + "requirements": ["Pillow==12.3.0"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 5c01cf26697a..bb537ccea6ea 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0", "simplehound==0.3"] + "requirements": ["Pillow==12.3.0", "simplehound==0.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 27b3ae60b2bf..39f1937a231d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ openai==2.21.0 orjson==3.11.9 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==12.2.0 +Pillow==12.3.0 propcache==0.5.2 psutil-home-assistant==0.0.1 PyJWT==2.12.1 diff --git a/pyproject.toml b/pyproject.toml index 4a86e436c694..7d545f071e6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ dependencies = [ "PyJWT==2.12.1", # PyJWT has loose dependency. We want the latest one. "cryptography==48.0.1", - "Pillow==12.2.0", + "Pillow==12.3.0", "propcache==0.5.2", "pyOpenSSL==26.2.0", "orjson==3.11.9", diff --git a/requirements.txt b/requirements.txt index af7dbd71e8ef..29e5c465204a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ lru-dict==1.4.1 mutagen==1.48.1 orjson==3.11.9 packaging>=23.1 -Pillow==12.2.0 +Pillow==12.3.0 propcache==0.5.2 psutil-home-assistant==0.0.1 PyJWT==2.12.1 diff --git a/requirements_all.txt b/requirements_all.txt index 43ee67ce7c5e..c60e8a801aa4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -38,7 +38,7 @@ PSNAWP==3.0.3 # homeassistant.components.qrcode # homeassistant.components.seven_segments # homeassistant.components.sighthound -Pillow==12.2.0 +Pillow==12.3.0 # homeassistant.components.plex PlexAPI==4.15.16 From 3930f64c7360a7879065cf18ebc39fd865dbb8ae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:07:11 +0200 Subject: [PATCH 425/707] Use LightEntityStateAttribute enum in MQTT light template (#175947) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/mqtt/light/schema_template.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index f9958db74253..80766f89499b 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -19,6 +19,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + LightEntityStateAttribute, filter_supported_color_modes, ) from homeassistant.const import ( @@ -361,17 +362,21 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): last_state = await self.async_get_last_state() if self._optimistic and last_state: self._attr_is_on = last_state.state == STATE_ON - if last_state.attributes.get(ATTR_BRIGHTNESS): - self._attr_brightness = last_state.attributes.get(ATTR_BRIGHTNESS) - if last_state.attributes.get(ATTR_HS_COLOR): - self._attr_hs_color = last_state.attributes.get(ATTR_HS_COLOR) + if brightness := last_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ): + self._attr_brightness = brightness + if hs_color := last_state.attributes.get( + LightEntityStateAttribute.HS_COLOR + ): + self._attr_hs_color = hs_color self._update_color_mode() - if last_state.attributes.get(ATTR_COLOR_TEMP_KELVIN): - self._attr_color_temp_kelvin = last_state.attributes.get( - ATTR_COLOR_TEMP_KELVIN - ) - if last_state.attributes.get(ATTR_EFFECT): - self._attr_effect = last_state.attributes.get(ATTR_EFFECT) + if color_temp_kelvin := last_state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ): + self._attr_color_temp_kelvin = color_temp_kelvin + if effect := last_state.attributes.get(LightEntityStateAttribute.EFFECT): + self._attr_effect = effect @override async def async_turn_on(self, **kwargs: Any) -> None: From 7296d1b93d670e919c4c0eb169a59d8a58f3214a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:19:29 +0200 Subject: [PATCH 426/707] Add new codeowner to HomeWizard (#176199) --- CODEOWNERS | 4 ++-- homeassistant/components/homewizard/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6a78706c143c..b3642514baf1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -779,8 +779,8 @@ CLAUDE.md @home-assistant/core /tests/components/homematicip_cloud/ @hahn-th @lackas /homeassistant/components/homevolt/ @danielhiversen @liudger /tests/components/homevolt/ @danielhiversen @liudger -/homeassistant/components/homewizard/ @DCSBL -/tests/components/homewizard/ @DCSBL +/homeassistant/components/homewizard/ @DCSBL @lexpostma +/tests/components/homewizard/ @DCSBL @lexpostma /homeassistant/components/honeywell/ @mkmer /tests/components/honeywell/ @mkmer /homeassistant/components/honeywell_string_lights/ @balloob diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index a1741d5d2319..f9a56ea3db9d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -1,7 +1,7 @@ { "domain": "homewizard", "name": "HomeWizard", - "codeowners": ["@DCSBL"], + "codeowners": ["@DCSBL", "@lexpostma"], "config_flow": true, "dhcp": [ { From 70c90f120078897ddcef596f76d271bb372629e3 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 10 Jul 2026 13:35:44 +0200 Subject: [PATCH 427/707] Add logger to Portainer (#176197) --- homeassistant/components/portainer/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index f60fe1e30700..9787cd141e7c 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "integration_type": "service", "iot_class": "local_polling", + "loggers": ["pyportainer"], "quality_scale": "platinum", "requirements": ["pyportainer==1.0.38"] } From c0ce4d18118de61ab265ef73704c46b288bf93bc Mon Sep 17 00:00:00 2001 From: Amit Krishna <218109745+amitkio@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:06:04 +0530 Subject: [PATCH 428/707] Bump energieleser to 0.1.5 (#176198) --- homeassistant/components/energieleser/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energieleser/manifest.json b/homeassistant/components/energieleser/manifest.json index b87071078e1a..160cffb2c9d3 100644 --- a/homeassistant/components/energieleser/manifest.json +++ b/homeassistant/components/energieleser/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["energieleser==0.1.4"], + "requirements": ["energieleser==0.1.5"], "zeroconf": [ { "type": "_stromleser._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index c60e8a801aa4..955e05f6f438 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -919,7 +919,7 @@ emoji==2.8.0 emulated-roku==0.3.0 # homeassistant.components.energieleser -energieleser==0.1.4 +energieleser==0.1.5 # homeassistant.components.huisbaasje energyflip-client==0.2.2 From 049314471d1c5c3e9da21e7b637a23b0f6352038 Mon Sep 17 00:00:00 2001 From: Linkplay2020 <65423368+Linkplay2020@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:36:26 +0800 Subject: [PATCH 429/707] Bump wiim to 0.1.5 (#176203) Co-authored-by: Tao Jiang --- homeassistant/components/wiim/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index a93652c0e978..83d724c308a4 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "bronze", - "requirements": ["wiim==0.1.4"], + "requirements": ["wiim==0.1.5"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 955e05f6f438..240b26e506ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3369,7 +3369,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wiim -wiim==0.1.4 +wiim==0.1.5 # homeassistant.components.wirelesstag wirelesstagpy==0.8.1 From 408dc80c4c26ea484af25deb4016845bf42c124f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 10 Jul 2026 13:36:55 +0200 Subject: [PATCH 430/707] Portainer dedicated endpoint button description (#176196) --- homeassistant/components/portainer/button.py | 43 ++++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index 8d4a2fd3ba5f..c9353ecf110c 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -37,8 +37,18 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class PortainerButtonDescription(ButtonEntityDescription): - """Class to describe a Portainer button entity.""" +class PortainerEndpointButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer endpoint button entity.""" + + press_action: Callable[ + [Portainer, int], + Coroutine[Any, Any, None | DockerContainer], + ] + + +@dataclass(frozen=True, kw_only=True) +class PortainerContainerButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer container button entity.""" press_action: Callable[ [Portainer, int, str], @@ -46,30 +56,30 @@ class PortainerButtonDescription(ButtonEntityDescription): ] -ENDPOINT_BUTTONS: tuple[PortainerButtonDescription, ...] = ( - PortainerButtonDescription( +ENDPOINT_BUTTONS: tuple[PortainerEndpointButtonDescription, ...] = ( + PortainerEndpointButtonDescription( key="images_prune", translation_key="images_prune", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=( - lambda portainer, endpoint_id, _: portainer.images_prune( + lambda portainer, endpoint_id: portainer.images_prune( endpoint_id=endpoint_id, dangling=False, until=timedelta(days=0) ) ), ), - PortainerButtonDescription( + PortainerEndpointButtonDescription( key="volumes_prune", translation_key="volumes_prune", entity_category=EntityCategory.CONFIG, press_action=( - lambda portainer, endpoint_id, _: portainer.prune_volumes(endpoint_id) + lambda portainer, endpoint_id: portainer.prune_volumes(endpoint_id) ), ), ) -CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( - PortainerButtonDescription( +CONTAINER_BUTTONS: tuple[PortainerContainerButtonDescription, ...] = ( + PortainerContainerButtonDescription( key="restart", translation_key="restart_container", device_class=ButtonDeviceClass.RESTART, @@ -80,7 +90,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="pause", translation_key="pause_container", entity_category=EntityCategory.CONFIG, @@ -90,7 +100,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="resume", translation_key="resume_container", entity_category=EntityCategory.CONFIG, @@ -100,7 +110,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="recreate", translation_key="recreate_container", entity_category=EntityCategory.CONFIG, @@ -113,7 +123,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="kill", translation_key="kill_container", entity_category=EntityCategory.CONFIG, @@ -186,7 +196,6 @@ class PortainerBaseButton(ButtonEntity): Ensures the async_press logic isn't duplicated. """ - entity_description: PortainerButtonDescription coordinator: PortainerCoordinator @abstractmethod @@ -220,20 +229,20 @@ class PortainerBaseButton(ButtonEntity): class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton): """Defines a Portainer endpoint button.""" - entity_description: PortainerButtonDescription + entity_description: PortainerEndpointButtonDescription @override async def _async_press_call(self) -> None: """Call the endpoint button press action.""" await self.entity_description.press_action( - self.coordinator.portainer, self.device_id, "" + self.coordinator.portainer, self.device_id ) class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton): """Defines a Portainer button.""" - entity_description: PortainerButtonDescription + entity_description: PortainerContainerButtonDescription @override async def _async_press_call(self) -> None: From ec016dcaf24068d45f9fb010f724dcab37f8d8da Mon Sep 17 00:00:00 2001 From: Jasper Slits Date: Fri, 10 Jul 2026 13:37:42 +0200 Subject: [PATCH 431/707] Bump dsmr_parser to 1.11.1 (#176192) --- homeassistant/components/dsmr/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 8255df921a85..dba3a4d6e314 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.11.0"] + "requirements": ["dsmr-parser==1.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 240b26e506ff..e3914358dd22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -856,7 +856,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.11.0 +dsmr-parser==1.11.1 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 From 58ad41bed717f88a944f481032d14f0ec30081c7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:38:43 +0200 Subject: [PATCH 432/707] Cleanup type ignore in find_coordinates helper (#176195) --- homeassistant/helpers/location.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index 772cb2d57290..9d268b74f6d5 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -76,13 +76,13 @@ def find_coordinates( # Check if entity_state is a zone zone_entity = hass.states.get(f"zone.{entity_state.state}") - if has_location(zone_entity): # type: ignore[arg-type] + if zone_entity and has_location(zone_entity): _LOGGER.debug( "%s is in %s, getting zone location", name, - zone_entity.entity_id, # type: ignore[union-attr] + zone_entity.entity_id, ) - return _get_location_from_attributes(zone_entity) # type: ignore[arg-type] + return _get_location_from_attributes(zone_entity) # Check if entity_state is a friendly name of a zone if (zone_coords := resolve_zone(hass, entity_state.state)) is not None: From d905433ad6ab66dd4f5afcdf2695508df6ecd771 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 10 Jul 2026 14:35:04 +0200 Subject: [PATCH 433/707] Call super class for `async_will_remove_from_hass` overrides in MQTT integration (#176207) --- homeassistant/components/mqtt/binary_sensor.py | 2 +- homeassistant/components/mqtt/entity.py | 7 ++++--- homeassistant/components/mqtt/sensor.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b67949b3c01d..f40d2d5fc04a 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -133,7 +133,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): self._expiration_trigger() self._expiration_trigger = None self._expired = False - await MqttEntity.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() @staticmethod @override diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index f50790616a07..65edaf103317 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -557,6 +557,7 @@ class MqttAttributesMixin(Entity): self._attributes_sub_state = async_unsubscribe_topics( self.hass, self._attributes_sub_state ) + await super().async_will_remove_from_hass() @callback def _attributes_message_received(self, msg: ReceiveMessage) -> None: @@ -708,6 +709,7 @@ class MqttAvailabilityMixin(Entity): self._availability_sub_state = async_unsubscribe_topics( self.hass, self._availability_sub_state ) + await super().async_will_remove_from_hass() @property @override @@ -1253,6 +1255,7 @@ class MqttDiscoveryUpdateMixin(Entity): async def async_will_remove_from_hass(self) -> None: """Stop listening to signal and cleanup discovery data.""" self._cleanup_discovery_on_remove() + await super().async_will_remove_from_hass() def _cleanup_discovery_on_remove(self) -> None: """Stop listening to signal and cleanup discovery data.""" @@ -1575,9 +1578,7 @@ class MqttEntity( self._sub_state = subscription.async_unsubscribe_topics( self.hass, self._sub_state ) - await MqttAttributesMixin.async_will_remove_from_hass(self) - await MqttAvailabilityMixin.async_will_remove_from_hass(self) - await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() debug_info.remove_entity_data(self.hass, self.entity_id) async def async_publish_with_config( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 8c6879fa534f..bab9483b3767 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -246,7 +246,7 @@ class MqttSensor(MqttEntity, RestoreSensor): self._expiration_trigger() self._expiration_trigger = None self._expired = False - await MqttEntity.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() @staticmethod @override From 871d722b109323fdf98d8918b4ecaf1d304f86f4 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Fri, 10 Jul 2026 14:58:00 +0200 Subject: [PATCH 434/707] Add active mode sensor for ViCare FloorHeating devices (#170103) --- homeassistant/components/vicare/sensor.py | 23 +++++++ homeassistant/components/vicare/strings.json | 8 +++ .../vicare/snapshots/test_sensor.ambr | 62 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 41fb2cc34ed3..dd52d40aae22 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -8,6 +8,7 @@ from typing import override from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareFloorHeating import FloorHeating from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) @@ -1274,6 +1275,16 @@ CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( SUPPLY_TEMPERATURE_SENSOR, ) +FLOOR_HEATING_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( + ViCareSensorEntityDescription( + key="active_mode", + translation_key="active_mode", + device_class=SensorDeviceClass.ENUM, + options=["cooling", "heating", "standby"], + value_getter=lambda api: api.getActiveMode(), + ), +) + BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="burner_starts", @@ -1509,6 +1520,18 @@ def _build_entities( for description in GLOBAL_SENSORS if is_supported(description.key, description.value_getter, device.api) ) + # add device-class-specific entities + if isinstance(device.api, FloorHeating): + entities.extend( + ViCareSensor( + description, + get_device_serial(device.api), + device.config, + device.api, + ) + for description in FLOOR_HEATING_SENSORS + if is_supported(description.key, description.value_getter, device.api) + ) # add component entities for component_list, entity_description_list in ( (get_circuits(device.api), CIRCUIT_SENSORS), diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 314dfd44e23f..92e2608b0a8d 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -161,6 +161,14 @@ } }, "sensor": { + "active_mode": { + "name": "Mode", + "state": { + "cooling": "Cooling", + "heating": "Heating", + "standby": "[%key:common::state::standby%]" + } + }, "boiler_supply_temperature": { "name": "Boiler supply temperature" }, diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 186bb6680c3b..c203876e7b35 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1218,6 +1218,68 @@ 'state': '46', }) # --- +# name: test_all_entities[sensor.model11_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'cooling', + 'heating', + 'standby', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model11_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_mode', + 'unique_id': 'gateway11_zigbee_################-active_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.model11_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'model11 Mode', + : list([ + 'cooling', + 'heating', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.model11_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }) +# --- # name: test_all_entities[sensor.model11_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 41c9a5c4e35096ec614c8713431a5329d04dff8b Mon Sep 17 00:00:00 2001 From: Yardian Support Date: Fri, 10 Jul 2026 21:37:28 +0800 Subject: [PATCH 435/707] Add a stop all zones button to Yardian (#174276) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/yardian/__init__.py | 1 + homeassistant/components/yardian/button.py | 44 ++++++++++++++++ homeassistant/components/yardian/const.py | 1 + homeassistant/components/yardian/strings.json | 5 ++ .../yardian/snapshots/test_button.ambr | 51 +++++++++++++++++++ tests/components/yardian/test_button.py | 46 +++++++++++++++++ 6 files changed, 148 insertions(+) create mode 100644 homeassistant/components/yardian/button.py create mode 100644 tests/components/yardian/snapshots/test_button.ambr create mode 100644 tests/components/yardian/test_button.py diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py index 96daa42561bb..b6b7ffcbef0d 100644 --- a/homeassistant/components/yardian/__init__.py +++ b/homeassistant/components/yardian/__init__.py @@ -10,6 +10,7 @@ from .coordinator import YardianConfigEntry, YardianUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/yardian/button.py b/homeassistant/components/yardian/button.py new file mode 100644 index 000000000000..379fa2c17df2 --- /dev/null +++ b/homeassistant/components/yardian/button.py @@ -0,0 +1,44 @@ +"""Support for Yardian buttons.""" + +import asyncio +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BUTTON_REFRESH_DELAY +from .coordinator import YardianConfigEntry, YardianUpdateCoordinator +from .entity import YardianEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: YardianConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Yardian button platform.""" + coordinator = entry.runtime_data + + async_add_entities([YardianStopButton(coordinator)]) + + +class YardianStopButton(YardianEntity, ButtonEntity): + """Representation of a Yardian Stop All Irrigation button.""" + + _attr_translation_key = "stop_irrigation" + + def __init__(self, coordinator: YardianUpdateCoordinator) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self.client = coordinator.controller + + self._attr_unique_id = f"{coordinator.yid}_stop_all" + + @override + async def async_press(self) -> None: + """Handle the button press.""" + await self.client.stop_irrigation() + + await asyncio.sleep(BUTTON_REFRESH_DELAY) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py index 4b77d8d8b79b..fcbedaed7ee9 100644 --- a/homeassistant/components/yardian/const.py +++ b/homeassistant/components/yardian/const.py @@ -6,3 +6,4 @@ PRODUCT_NAME = "Yardian Smart Sprinkler Controller" DEFAULT_WATERING_DURATION = 6 SWITCH_REFRESH_DELAY = 2 +BUTTON_REFRESH_DELAY = 3 diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 50db7dd91cf6..b9687238016b 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -35,6 +35,11 @@ "name": "Enabled" } }, + "button": { + "stop_irrigation": { + "name": "Stop irrigation" + } + }, "sensor": { "active_zone_count": { "name": "Active zones", diff --git a/tests/components/yardian/snapshots/test_button.ambr b/tests/components/yardian/snapshots/test_button.ambr new file mode 100644 index 000000000000..b26b7cd70b6c --- /dev/null +++ b/tests/components/yardian/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[button.yardian_smart_sprinkler_stop_irrigation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.yardian_smart_sprinkler_stop_irrigation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stop irrigation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop irrigation', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop_irrigation', + 'unique_id': 'yid123_stop_all', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.yardian_smart_sprinkler_stop_irrigation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Yardian Smart Sprinkler Stop irrigation', + }), + 'context': , + 'entity_id': 'button.yardian_smart_sprinkler_stop_irrigation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/yardian/test_button.py b/tests/components/yardian/test_button.py new file mode 100644 index 000000000000..6134624de6d0 --- /dev/null +++ b/tests/components/yardian/test_button.py @@ -0,0 +1,46 @@ +"""Validate Yardian button behavior.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.yardian.PLATFORMS", [Platform.BUTTON]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all button entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@patch("homeassistant.components.yardian.button.BUTTON_REFRESH_DELAY", 0) +async def test_stop_all_button( + hass: HomeAssistant, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing the stop irrigation button.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.yardian_smart_sprinkler_stop_irrigation"}, + blocking=True, + ) + mock_yardian_client.stop_irrigation.assert_called_once() From 3b02aa26961e49df91cb238c5aa7bc93d3a668a4 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 10 Jul 2026 15:46:31 +0200 Subject: [PATCH 436/707] Portainer remove err translations (#176190) --- homeassistant/components/portainer/button.py | 6 +++--- homeassistant/components/portainer/coordinator.py | 8 -------- homeassistant/components/portainer/services.py | 12 ++++++------ homeassistant/components/portainer/strings.json | 9 --------- homeassistant/components/portainer/switch.py | 6 +++--- homeassistant/components/portainer/update.py | 4 ++-- tests/components/portainer/test_services.py | 6 +++--- 7 files changed, 17 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index c9353ecf110c..b2a4dd8b7d73 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -210,17 +210,17 @@ class PortainerBaseButton(ButtonEntity): except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index a876fa2a1135..80d3d37e3273 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -141,19 +141,16 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except PortainerTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err @abstractmethod @@ -169,19 +166,16 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except PortainerTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err @@ -221,13 +215,11 @@ class PortainerCoordinator( raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err mapped_endpoints: dict[int, PortainerCoordinatorData] = {} diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index 6ed749d5abff..bbd910bd8cd0 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -136,17 +136,17 @@ async def prune_images(call: ServiceCall) -> None: except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err @@ -170,17 +170,17 @@ async def recreate_container(call: ServiceCall) -> None: except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err await coordinator.async_request_refresh() diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 1a2854ac3e87..d32ebe40ce42 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -201,24 +201,15 @@ }, "exceptions": { "cannot_connect": { - "message": "An error occurred while trying to connect to the Portainer instance: {error}" - }, - "cannot_connect_no_details": { "message": "An error occurred while trying to connect to the Portainer instance." }, "invalid_auth": { - "message": "An error occurred while trying to authenticate: {error}" - }, - "invalid_auth_no_details": { "message": "An error occurred while trying to authenticate." }, "invalid_target": { "message": "Invalid device targeted." }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Portainer instance: {error}" - }, - "timeout_connect_no_details": { "message": "A timeout occurred while trying to connect to the Portainer instance." } }, diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index e95274a0103c..7c87e056d01c 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -65,17 +65,17 @@ async def _perform_action( except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err else: await coordinator.async_request_refresh() diff --git a/homeassistant/components/portainer/update.py b/homeassistant/components/portainer/update.py index a40b43e8d61a..da3d3f529e86 100644 --- a/homeassistant/components/portainer/update.py +++ b/homeassistant/components/portainer/update.py @@ -174,12 +174,12 @@ class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from ex except PortainerConnectionError as ex: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from ex else: await self.coordinator.async_request_refresh() diff --git a/tests/components/portainer/test_services.py b/tests/components/portainer/test_services.py index f5c3ecc0bff0..deeff7cdc98d 100644 --- a/tests/components/portainer/test_services.py +++ b/tests/components/portainer/test_services.py @@ -162,15 +162,15 @@ async def test_service_recreate_container( [ ( PortainerAuthenticationError("auth"), - "invalid_auth_no_details", + "invalid_auth", ), ( PortainerConnectionError("conn"), - "cannot_connect_no_details", + "cannot_connect", ), ( PortainerTimeoutError("timeout"), - "timeout_connect_no_details", + "timeout_connect", ), ], ) From fe66aa080a482349fbe61643d9e803fe3f7f3b99 Mon Sep 17 00:00:00 2001 From: WilliamCoenen <57435489+WilliamCoenen@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:21:11 +0200 Subject: [PATCH 437/707] Fix Velbus power and energy sensors for counter channels (#168201) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/velbus/sensor.py | 7 ++++++- tests/components/velbus/conftest.py | 3 ++- tests/components/velbus/test_sensor.py | 18 ++++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 3adce293632b..2125663e4bf0 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -40,6 +40,7 @@ SENSOR_DESCRIPTIONS: dict[str, VelbusSensorEntityDescription] = { key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda channel: float(channel.get_counter_state()), unit_fn=lambda channel: channel.get_unit(), ), "temperature": VelbusSensorEntityDescription( @@ -58,7 +59,11 @@ SENSOR_DESCRIPTIONS: dict[str, VelbusSensorEntityDescription] = { device_class=SensorDeviceClass.ENERGY, icon="mdi:counter", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda channel: float(channel.get_counter_state()), + value_fn=lambda channel: ( + float(channel.energy) + if hasattr(channel, "energy") and channel.energy is not None + else None + ), unit_fn=lambda channel: channel.get_counter_unit(), unique_id_suffix="-counter", ), diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 12f61e451de1..643f9f73e7a4 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import pytest from velbusaio.channels import ( @@ -209,6 +209,7 @@ def mock_buttoncounter() -> AsyncMock: channel.get_state.return_value = 100 channel.get_unit.return_value = "W" channel.get_counter_state.return_value = 100 + type(channel).energy = PropertyMock(return_value=100.0) channel.get_counter_unit.return_value = "kWh" return channel diff --git a/tests/components/velbus/test_sensor.py b/tests/components/velbus/test_sensor.py index d89d2de59db6..a546a7622d2c 100644 --- a/tests/components/velbus/test_sensor.py +++ b/tests/components/velbus/test_sensor.py @@ -1,10 +1,10 @@ """Velbus sensor platform tests.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, PropertyMock, patch from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,3 +24,17 @@ async def test_entities( await init_integration(hass, config_entry) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_vmb8in_counter_energy_unavailable_when_no_energy( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_buttoncounter: AsyncMock, +) -> None: + """Test VMB8IN-20 counter sensor is unknown when energy has not been received.""" + type(mock_buttoncounter).energy = PropertyMock(return_value=None) + await init_integration(hass, config_entry) + + state = hass.states.get("sensor.input_buttoncounter_counter") + assert state is not None + assert state.state == STATE_UNKNOWN From dd9754d21cbd6098f9962a1ad093785192782bab Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 10 Jul 2026 16:33:59 +0200 Subject: [PATCH 438/707] Remove error placeholders from Proxmox (#176188) --- homeassistant/components/proxmoxve/button.py | 8 ++++---- .../components/proxmoxve/coordinator.py | 9 --------- .../components/proxmoxve/strings.json | 18 +++--------------- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index e18a9922685d..5c5bdda0f114 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -324,22 +324,22 @@ class ProxmoxBaseButton(ButtonEntity): except AuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except SSLError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except ConnectTimeout as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err except (ResourceException, requests.exceptions.ConnectionError) as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="api_error_no_details", + translation_key="api_error_details", ) from err diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index e9cfffc16f6b..b701fa975a8b 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -110,25 +110,21 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except SSLError as err: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="ssl_error", - translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err except ProxmoxServerError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_error_details", - translation_placeholders={"error": repr(err)}, ) from err except ProxmoxPermissionsError as err: raise ConfigEntryAuthFailed( @@ -144,7 +140,6 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise ConfigEntryError( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err @override @@ -157,19 +152,16 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except SSLError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="ssl_error", - translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err except ResourceException as err: raise UpdateFailed( @@ -180,7 +172,6 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err data: dict[str, ProxmoxNodeData] = {} diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 62316d3508e0..fd35574b8fc2 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -297,22 +297,13 @@ }, "exceptions": { "api_error_details": { - "message": "An error occurred while communicating with the Proxmox VE instance: {error}" - }, - "api_error_no_details": { "message": "An error occurred while communicating with the Proxmox VE instance." }, "cannot_connect": { - "message": "An error occurred while trying to connect to the Proxmox VE instance: {error}" - }, - "cannot_connect_no_details": { - "message": "Could not connect to the Proxmox VE instance." + "message": "An error occurred while trying to connect to the Proxmox VE instance." }, "invalid_auth": { - "message": "An error occurred while trying to authenticate: {error}" - }, - "invalid_auth_no_details": { - "message": "Authentication failed for the Proxmox VE instance." + "message": "An error occurred while trying to authenticate." }, "no_nodes_found": { "message": "No active nodes were found on the Proxmox VE server." @@ -333,12 +324,9 @@ "message": "Failed to retrieve Proxmox VE permissions. Please check your credentials and try again." }, "ssl_error": { - "message": "An SSL error occurred: {error}" + "message": "An SSL error occurred." }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}" - }, - "timeout_connect_no_details": { "message": "A timeout occurred while trying to connect to the Proxmox VE instance." } }, From 55ac2ca921ae66e7af639d82e3730ed7f71110b1 Mon Sep 17 00:00:00 2001 From: Maximilian <43999966+DeerMaximum@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:05:38 +0200 Subject: [PATCH 439/707] Use actions in NINA to allow accessing data (#166125) --- homeassistant/components/nina/__init__.py | 11 +++ .../components/nina/binary_sensor.py | 32 +++++++++ homeassistant/components/nina/const.py | 14 ++++ homeassistant/components/nina/icons.json | 24 +++++++ .../components/nina/quality_scale.yaml | 17 ++--- homeassistant/components/nina/services.py | 21 ++++++ homeassistant/components/nina/services.yaml | 5 ++ homeassistant/components/nina/strings.json | 6 ++ .../nina/snapshots/test_service.ambr | 18 +++++ tests/components/nina/test_service.py | 67 +++++++++++++++++++ 10 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/nina/icons.json create mode 100644 homeassistant/components/nina/services.py create mode 100644 homeassistant/components/nina/services.yaml create mode 100644 tests/components/nina/snapshots/test_service.ambr create mode 100644 tests/components/nina/test_service.py diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 24feb44320b5..f06a06ffeeb2 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -4,6 +4,8 @@ from typing import Any from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( _LOGGER, @@ -12,11 +14,14 @@ from .const import ( CONF_FILTER_CORONA, CONF_FILTERS, CONF_HEADLINE_FILTER, + DOMAIN, NO_MATCH_REGEX, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: @@ -32,6 +37,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool return True +async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: + """Set up services.""" + async_setup_services(hass) + return True + + async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index d7e4a4711dd0..d5e53cbcd359 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -23,6 +23,17 @@ from .const import ( ATTR_WEB, CONF_MESSAGE_SLOTS, CONF_REGIONS, + SERVICE_DATA_AFFECTED_AREAS, + SERVICE_DATA_DESCRIPTION, + SERVICE_DATA_EXPIRES, + SERVICE_DATA_HEADLINE, + SERVICE_DATA_ID, + SERVICE_DATA_RECOMMENDED_ACTIONS, + SERVICE_DATA_SENDER, + SERVICE_DATA_SENT, + SERVICE_DATA_SEVERITY, + SERVICE_DATA_START, + SERVICE_DATA_WEB, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator from .entity import NinaEntity @@ -105,3 +116,24 @@ class NINAMessage(NinaEntity, BinarySensorEntity): if data.expires else "", # Deprecated, remove in 2026.11 } + + def get_details(self) -> dict[str, str] | None: + """Return the details of the warning.""" + if not self.is_on: + return None + + data = self._get_warning_data() + + return { + SERVICE_DATA_HEADLINE: data.headline, + SERVICE_DATA_DESCRIPTION: data.description, + SERVICE_DATA_SENDER: data.sender, + SERVICE_DATA_SEVERITY: data.severity or "Unknown", + SERVICE_DATA_RECOMMENDED_ACTIONS: data.recommended_actions, + SERVICE_DATA_AFFECTED_AREAS: data.affected_areas, + SERVICE_DATA_WEB: data.more_info_url, + SERVICE_DATA_ID: data.id, + SERVICE_DATA_SENT: data.sent.isoformat(), + SERVICE_DATA_START: data.start.isoformat() if data.start else "", + SERVICE_DATA_EXPIRES: data.expires.isoformat() if data.expires else "", + } diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 9af64322eacf..56e69dc4fc1f 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -15,6 +15,20 @@ ALL_MATCH_REGEX: str = ".*" SEVERITY_VALUES: list[str] = ["extreme", "severe", "moderate", "minor", "unknown"] +SERVICE_GET_DETAILS: str = "get_details" + +SERVICE_DATA_HEADLINE: str = "headline" +SERVICE_DATA_DESCRIPTION: str = "description" +SERVICE_DATA_SENDER: str = "sender" +SERVICE_DATA_SEVERITY: str = "severity" +SERVICE_DATA_RECOMMENDED_ACTIONS: str = "recommended_actions" +SERVICE_DATA_AFFECTED_AREAS: str = "affected_areas" +SERVICE_DATA_WEB: str = "web" +SERVICE_DATA_ID: str = "id" +SERVICE_DATA_SENT: str = "sent" +SERVICE_DATA_START: str = "start" +SERVICE_DATA_EXPIRES: str = "expires" + CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" CONF_FILTERS: str = "filters" diff --git a/homeassistant/components/nina/icons.json b/homeassistant/components/nina/icons.json new file mode 100644 index 000000000000..f0b4ac1a9115 --- /dev/null +++ b/homeassistant/components/nina/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "affected_areas": { + "default": "mdi:map-marker-radius" + }, + "headline": { + "default": "mdi:text-short" + }, + "more_info_url": { + "default": "mdi:web" + }, + "sender": { + "default": "mdi:account-tie-voice" + }, + "severity": { + "default": "mdi:alert" + } + } + }, + "services": { + "get_details": { "service": "mdi:download" } + } +} diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml index 9f0f051f6549..39e827890a00 100644 --- a/homeassistant/components/nina/quality_scale.yaml +++ b/homeassistant/components/nina/quality_scale.yaml @@ -1,19 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: | - This integration does not provide additional actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - This integration does not provide additional actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -35,10 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - This integration does not provide additional actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -78,7 +69,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/nina/services.py b/homeassistant/components/nina/services.py new file mode 100644 index 000000000000..b26e9394de25 --- /dev/null +++ b/homeassistant/components/nina/services.py @@ -0,0 +1,21 @@ +"""Services for NINA.""" + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_GET_DETAILS + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_DETAILS, + entity_domain=BINARY_SENSOR_DOMAIN, + schema=None, + func="get_details", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/nina/services.yaml b/homeassistant/components/nina/services.yaml new file mode 100644 index 000000000000..2c57ec07fc2a --- /dev/null +++ b/homeassistant/components/nina/services.yaml @@ -0,0 +1,5 @@ +get_details: + target: + entity: + domain: binary_sensor + integration: nina diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 2e36e2fd61e0..ad7caa06570f 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -129,5 +129,11 @@ "title": "Options" } } + }, + "services": { + "get_details": { + "description": "Get the details of a warning.", + "name": "Get warning details" + } } } diff --git a/tests/components/nina/snapshots/test_service.ambr b/tests/components/nina/snapshots/test_service.ambr new file mode 100644 index 000000000000..e5e91fd7b997 --- /dev/null +++ b/tests/components/nina/snapshots/test_service.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_service_get_details + dict({ + 'binary_sensor.aach_warning_1': dict({ + 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', + 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', + 'expires': '3021-11-22T05:19:00+01:00', + 'headline': 'Ausfall Notruf 112', + 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', + 'recommended_actions': 'ACHTUNG! Hinweis auf mögliche Gefahren: Es können zum Beispiel einzelne Äste herabstürzen. Achte besonders auf herabfallende Gegenstände.', + 'sender': 'Deutscher Wetterdienst', + 'sent': '2021-10-11T05:20:00+01:00', + 'severity': 'Minor', + 'start': '2021-11-01T05:20:00+01:00', + 'web': 'https://www.wettergefahren.de', + }), + }) +# --- diff --git a/tests/components/nina/test_service.py b/tests/components/nina/test_service.py new file mode 100644 index 000000000000..a10b2efece13 --- /dev/null +++ b/tests/components/nina/test_service.py @@ -0,0 +1,67 @@ +"""Test the Nina services.""" + +from unittest.mock import AsyncMock + +from pynina import Warning +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nina.const import DOMAIN, SERVICE_GET_DETAILS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import setup_platform, setup_single_platform + +from tests.common import MockConfigEntry + + +async def test_service_registration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nina_class: AsyncMock +) -> None: + """Test the NINA services be registered.""" + await setup_single_platform(hass, mock_config_entry, None, mock_nina_class, []) + + services = hass.services.async_services_for_domain(DOMAIN) + + assert len(services) == 1 + assert SERVICE_GET_DETAILS in services + + +async def test_service_get_details( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nina_class: AsyncMock, + nina_warnings: list[Warning], + snapshot: SnapshotAssertion, +) -> None: + """Test the get details service.""" + await setup_platform(hass, mock_config_entry, mock_nina_class, nina_warnings) + + target_entity_id = "binary_sensor.aach_warning_1" + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DETAILS, + {ATTR_ENTITY_ID: target_entity_id}, + blocking=True, + return_response=True, + ) + assert result == snapshot + + +async def test_service_get_details_no_warning( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nina_class: AsyncMock +) -> None: + """Test the get details service when no warning is present.""" + await setup_platform(hass, mock_config_entry, mock_nina_class, []) + + target_entity_id = "binary_sensor.aach_warning_1" + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DETAILS, + {ATTR_ENTITY_ID: target_entity_id}, + blocking=True, + return_response=True, + ) + + assert result[target_entity_id] is None From 84f18b7b938b8c1b24e9d191bdddebda6830dc9b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 10 Jul 2026 17:18:42 +0200 Subject: [PATCH 440/707] Update frontend to 20260624.5 (#176216) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 14996cd487e2..db81df79958a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.4"] + "requirements": ["home-assistant-frontend==20260624.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 39f1937a231d..2308d37fb0dc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.4 +home-assistant-frontend==20260624.5 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index f9e4b3d4ebec..8cdb0231c569 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.4" +FRONTEND_VERSION: Final[str] = "20260624.5" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index e3914358dd22..e1ea240064d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1269,7 +1269,7 @@ hole==0.9.2 holidays==0.100 # homeassistant.components.frontend -home-assistant-frontend==20260624.4 +home-assistant-frontend==20260624.5 # homeassistant.components.conversation home-assistant-intents==2026.6.24 From 42e1e792b183e373f6ea1c243c5e598d2126e54a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:20:46 +0200 Subject: [PATCH 441/707] Fix Teslemetry cabin overheat protection restored temperatures (#175973) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/teslemetry/climate.py | 4 ++-- tests/components/teslemetry/snapshots/test_climate.ambr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 8872ad174536..a4268319b3bc 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -531,8 +531,8 @@ class TeslemetryStreamingCabinOverheatProtectionEntity( self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("temperature") - self._attr_target_temperature = state.attributes.get("target_temperature") + self._attr_current_temperature = state.attributes.get("current_temperature") + self._attr_target_temperature = state.attributes.get("temperature") self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index e65442579b2f..bff34849d51c 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -410,7 +410,7 @@ # name: test_select_streaming[climate.test_cabin_overheat_protection] StateSnapshot({ 'attributes': ReadOnlyDict({ - : None, + : 26, : 'Test Cabin overheat protection', : list([ , From 6772f43d83fcfa9bb18a6d7b370e01c398dbd569 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 10 Jul 2026 10:21:16 -0500 Subject: [PATCH 442/707] Include ConnectError in Ollama config flow (#176147) Co-authored-by: Erwin Douna --- .../components/ollama/config_flow.py | 4 ++-- tests/components/ollama/test_config_flow.py | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 78a3b3a9636f..19f79bff7a0f 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -118,7 +118,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): str(err), ) errors["base"] = "unknown" - except TimeoutError, httpx.ConnectError: + except TimeoutError, ConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") @@ -278,7 +278,7 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow): downloaded_models: set[str] = { model_info["model"] for model_info in response.get("models", []) } - except TimeoutError, httpx.ConnectError, httpx.HTTPError: + except TimeoutError, httpx.HTTPError, ConnectionError: _LOGGER.exception("Failed to get models from Ollama server") return self.async_abort(reason="cannot_connect") diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 712f3cfc322f..77ece15c131c 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import ANY, AsyncMock, patch -from httpx import ConnectError +from httpx import HTTPError from ollama import ResponseError import pytest @@ -378,7 +378,8 @@ async def test_reauth_flow_success( ("side_effect", "error"), [ (ResponseError(error="Unauthorized", status_code=401), "invalid_auth"), - (ConnectError(message="Connection failed"), "cannot_connect"), + (ConnectionError("Connection failed"), "cannot_connect"), + (TimeoutError(), "cannot_connect"), ], ) async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> None: @@ -437,7 +438,8 @@ async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> No @pytest.mark.parametrize( ("side_effect", "error"), [ - (ConnectError(message=""), "cannot_connect"), + (ConnectionError("Failed to connect to Ollama"), "cannot_connect"), + (TimeoutError(), "cannot_connect"), (RuntimeError(), "unknown"), ], ) @@ -462,7 +464,8 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: @pytest.mark.parametrize( ("side_effect", "error"), [ - (ConnectError(message=""), "cannot_connect"), + (ConnectionError(), "cannot_connect"), + (TimeoutError(), "cannot_connect"), (RuntimeError(), "unknown"), ], ) @@ -513,15 +516,24 @@ async def test_form_invalid_url(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "invalid_url"} +@pytest.mark.parametrize( + "side_effect", + [ + TimeoutError(), + ConnectionError("Failed to connect to Ollama"), + HTTPError("HTTP error"), + ], +) async def test_subentry_connection_error( hass: HomeAssistant, mock_init_component, mock_config_entry: MockConfigEntry, + side_effect: Exception, ) -> None: """Test subentry creation when connection to Ollama server fails.""" with patch( "ollama.AsyncClient.list", - side_effect=ConnectError("Connection failed"), + side_effect=side_effect, ): new_flow = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "conversation"), From d42d115a132c1c24f500acb51c955362cdb56676 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jul 2026 05:21:31 -1000 Subject: [PATCH 443/707] Skip global ESPHome update lock when dashboard has a build queue (#176162) --- .../components/esphome/coordinator.py | 17 ++-- homeassistant/components/esphome/update.py | 36 ++++--- tests/components/esphome/test_update.py | 93 +++++++++++++++++++ 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index dfe4741a0d6e..ec4e2ad9cc43 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") +MIN_VERSION_SUPPORTS_BUILD_QUEUE = AwesomeVersion("2026.6.0") REFRESH_INTERVAL = timedelta(minutes=5) @@ -34,6 +35,7 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi self.url = url self.api = ESPHomeDashboardAPI(url, async_get_clientsession(hass)) self.supports_update: bool | None = None + self.supports_build_queue = False @override async def _async_update_data(self) -> dict[str, ConfiguredDevice]: @@ -41,13 +43,14 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi devices = await self.api.get_devices() configured_devices = devices["configured"] - if ( - self.supports_update is None - and configured_devices - and (current_version := configured_devices[0].get("current_version")) + if configured_devices and ( + current_version := configured_devices[0].get("current_version") ): - self.supports_update = ( - AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE - ) + version = AwesomeVersion(current_version) + if self.supports_update is None: + self.supports_update = version > MIN_VERSION_SUPPORTS_UPDATE + # The dashboard has its own build queue since 2026.6.0 + # and can accept multiple compile requests at once + self.supports_build_queue = version >= MIN_VERSION_SUPPORTS_BUILD_QUEUE return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index eff6c6ab4b73..2b6f49272c04 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -233,21 +233,27 @@ class ESPHomeDashboardUpdateEntity( # Ensure only one OTA per device at a time async with self._install_lock: - # Ensure only one compile at a time for ALL devices - async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): - coordinator = self.coordinator - api = coordinator.api - device = coordinator.data.get(self._device_info.name) - assert device is not None - configuration = device["configuration"] - if not await api.compile(configuration): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="error_compiling", - translation_placeholders={ - "configuration": configuration, - }, - ) + coordinator = self.coordinator + api = coordinator.api + device = coordinator.data.get(self._device_info.name) + assert device is not None + configuration = device["configuration"] + if coordinator.supports_build_queue: + # The dashboard has its own build queue + # and can handle concurrent compile requests + compiled = await api.compile(configuration) + else: + # Ensure only one compile at a time for ALL devices + async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): + compiled = await api.compile(configuration) + if not compiled: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_compiling", + translation_placeholders={ + "configuration": configuration, + }, + ) # If the device uses deep sleep, there's a small chance it goes # to sleep right after the dashboard connects but before the OTA diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 1bdb4b4ac637..c29606fdff1c 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -10,6 +10,7 @@ from awesomeversion.exceptions import AwesomeVersionCompareException import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard +from homeassistant.components.esphome.update import KEY_UPDATE_LOCK from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -811,6 +812,98 @@ async def test_attempt_to_update_twice( await update_task +async def test_update_dashboard_with_build_queue_skips_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock is skipped when the dashboard has a build queue.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.6.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + # Hold the global compile lock; the install must not need it + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + lock.release() + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + +async def test_update_dashboard_without_build_queue_waits_for_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock still serializes installs on older dashboards.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.5.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + # The compile must be blocked on the global lock + assert len(mock_compile.mock_calls) == 0 + lock.release() + await update_task + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + async def test_update_deep_sleep_already_online( hass: HomeAssistant, mock_client: APIClient, From c0047cfe0df1a32655fbbf9ca0c729df9f3847f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:25:23 +0200 Subject: [PATCH 444/707] Bump tuya-device-handlers to 0.0.25 (#176213) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 18f5551f7fbd..5e9076c213ec 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.24", + "tuya-device-handlers==0.0.25", "tuya-device-sharing-sdk==0.2.10" ] } diff --git a/requirements_all.txt b/requirements_all.txt index e1ea240064d4..ed824d5ed04f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3225,7 +3225,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.24 +tuya-device-handlers==0.0.25 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.10 From c43ab4dbd18e6e48ca60817d1543961b22f5e86c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:26:54 +0200 Subject: [PATCH 445/707] Use base EntityStateAttribute for device tracker coordinates (#176191) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/device_tracker/const.py | 14 ++++++++++--- .../components/device_tracker/entity.py | 5 +++-- .../components/device_tracker/legacy.py | 10 +++++----- .../components/tesla_fleet/device_tracker.py | 14 ++++--------- .../snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 4 ++-- .../ituran/snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 4 ++-- .../lojack/snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 20 +++++++++---------- .../snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 8 ++++---- .../snapshots/test_device_tracker.ambr | 16 +++++++-------- .../tessie/snapshots/test_device_tracker.ambr | 8 ++++---- .../tile/snapshots/test_device_tracker.ambr | 4 ++-- .../snapshots/test_device_tracker.ambr | 4 ++-- .../volvo/snapshots/test_device_tracker.ambr | 16 +++++++-------- 20 files changed, 79 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 920e0f5994e6..7fcbb74ca3d0 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -5,6 +5,7 @@ from enum import StrEnum import logging from typing import Final +from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers from homeassistant.util.signal_type import SignalType LOGGER: Final = logging.getLogger(__package__) @@ -50,11 +51,18 @@ class DeviceTrackerEntityStateAttribute(StrEnum): IN_ZONES = "in_zones" -class TrackerEntityStateAttribute(StrEnum): +class TrackerEntityStateAttribute( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "LATITUDE": ("EntityStateAttribute.LATITUDE", "2027.2.0"), + "LONGITUDE": ("EntityStateAttribute.LONGITUDE", "2027.2.0"), + }, +): """State attributes set by TrackerEntity.""" - LATITUDE = "latitude" - LONGITUDE = "longitude" + LATITUDE = "latitude" # Deprecated, replaced with EntityStateAttribute.LATITUDE + LONGITUDE = "longitude" # Deprecated, replaced with EntityStateAttribute.LONGITUDE GPS_ACCURACY = "gps_accuracy" diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index 0b4b8bde1e0c..c12320dcb16e 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -16,6 +16,7 @@ from homeassistant.const import ( # noqa: F401 STATE_HOME, STATE_NOT_HOME, EntityCategory, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -422,8 +423,8 @@ class TrackerEntity( attr.update(super().state_attributes) if self.latitude is not None and self.longitude is not None: - attr[TrackerEntityStateAttribute.LATITUDE] = self.latitude - attr[TrackerEntityStateAttribute.LONGITUDE] = self.longitude + attr[EntityStateAttribute.LATITUDE] = self.latitude + attr[EntityStateAttribute.LONGITUDE] = self.longitude attr[TrackerEntityStateAttribute.GPS_ACCURACY] = self.location_accuracy return attr diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index ef66d875a22c..a3fb9fdb4457 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -846,8 +846,8 @@ class Device(RestoreEntity): } if self.gps is not None: - attributes[TrackerEntityStateAttribute.LATITUDE] = self.gps[0] - attributes[TrackerEntityStateAttribute.LONGITUDE] = self.gps[1] + attributes[EntityStateAttribute.LATITUDE] = self.gps[0] + attributes[EntityStateAttribute.LONGITUDE] = self.gps[1] attributes[TrackerEntityStateAttribute.GPS_ACCURACY] = self.gps_accuracy if self.battery is not None: @@ -962,10 +962,10 @@ class Device(RestoreEntity): if attribute in state.attributes: setattr(self, var, state.attributes[attribute]) - if TrackerEntityStateAttribute.LONGITUDE in state.attributes: + if EntityStateAttribute.LONGITUDE in state.attributes: self.gps = ( - state.attributes[TrackerEntityStateAttribute.LATITUDE], - state.attributes[TrackerEntityStateAttribute.LONGITUDE], + state.attributes[EntityStateAttribute.LATITUDE], + state.attributes[EntityStateAttribute.LONGITUDE], ) diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 986c342c50a6..825af496c660 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -2,11 +2,9 @@ from typing import override -from homeassistant.components.device_tracker import ( - TrackerEntity, - TrackerEntityStateAttribute, -) +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -55,12 +53,8 @@ class TeslaFleetDeviceTrackerEntity( and self._attr_latitude is None and self._attr_longitude is None ): - self._attr_latitude = state.attributes.get( - TrackerEntityStateAttribute.LATITUDE - ) - self._attr_longitude = state.attributes.get( - TrackerEntityStateAttribute.LONGITUDE - ) + self._attr_latitude = state.attributes.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = state.attributes.get(EntityStateAttribute.LONGITUDE) class TeslaFleetDeviceTrackerLocationEntity(TeslaFleetDeviceTrackerEntity): diff --git a/tests/components/autoskope/snapshots/test_device_tracker.ambr b/tests/components/autoskope/snapshots/test_device_tracker.ambr index 8b66511a8d4f..686ff63b8efa 100644 --- a/tests/components/autoskope/snapshots/test_device_tracker.ambr +++ b/tests/components/autoskope/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ : 'mdi:car', : list([ ]), - : 50.1109221, - : 8.6821267, + : 50.1109221, + : 8.6821267, : , : , }), diff --git a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr index fc5568f903de..01dc327459ef 100644 --- a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr +++ b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ : 10.0, : list([ ]), - : 52.520008, - : 13.404954, + : 52.520008, + : 13.404954, : , : , }), diff --git a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr index 475a5969e791..4d3dace8d92d 100644 --- a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : 35.5402913, - : -82.5527055, + : 35.5402913, + : -82.5527055, : , : , }), diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr index b7eaca1a04d5..80aeff1966a1 100644 --- a/tests/components/ituran/snapshots/test_device_tracker.ambr +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : 25.0, - : -71.0, + : 25.0, + : -71.0, : , : , }), diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index 65bcc16db700..b919d0f61957 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -24,8 +24,8 @@ : list([ 'zone.home', ]), - : 32.87336, - : -117.22743, + : 32.87336, + : -117.22743, : , : , }), diff --git a/tests/components/lojack/snapshots/test_device_tracker.ambr b/tests/components/lojack/snapshots/test_device_tracker.ambr index 00885d4ddafd..91fd506cbdfb 100644 --- a/tests/components/lojack/snapshots/test_device_tracker.ambr +++ b/tests/components/lojack/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 10, : list([ ]), - : 37.7749, - : -122.4194, + : 37.7749, + : -122.4194, : , : , }), diff --git a/tests/components/nrgkick/snapshots/test_device_tracker.ambr b/tests/components/nrgkick/snapshots/test_device_tracker.ambr index fee6fa604b8d..6fd55e796937 100644 --- a/tests/components/nrgkick/snapshots/test_device_tracker.ambr +++ b/tests/components/nrgkick/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 1.5, : list([ ]), - : 47.0748, - : 15.4376, + : 47.0748, + : 15.4376, : , : , }), diff --git a/tests/components/paj_gps/snapshots/test_device_tracker.ambr b/tests/components/paj_gps/snapshots/test_device_tracker.ambr index 8648618b5398..fddb2122f281 100644 --- a/tests/components/paj_gps/snapshots/test_device_tracker.ambr +++ b/tests/components/paj_gps/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ : 'mdi:map-marker', : list([ ]), - : 52.0, - : 13.0, + : 52.0, + : 13.0, : , : , }), diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index d30314d06c32..2d014c9079c9 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -154,8 +154,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -213,8 +213,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -272,8 +272,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -331,8 +331,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -390,8 +390,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), diff --git a/tests/components/template/snapshots/test_device_tracker.ambr b/tests/components/template/snapshots/test_device_tracker.ambr index 2d27c9c770e4..9066306a81bb 100644 --- a/tests/components/template/snapshots/test_device_tracker.ambr +++ b/tests/components/template/snapshots/test_device_tracker.ambr @@ -6,8 +6,8 @@ : 10.0, : list([ ]), - : 10.0, - : 40.0, + : 10.0, + : 40.0, : , : , }), diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index ec276c5436fb..602778a6bf23 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , : , }), @@ -104,8 +104,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 3644f1530207..6c5f86dbef5f 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , : , }), @@ -104,8 +104,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), @@ -124,8 +124,8 @@ : 0, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , : , }), @@ -144,8 +144,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index 429624290317..30d548927ec4 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ 'heading': 185, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , 'speed': None, : , @@ -106,8 +106,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index 12ee4ca3e6e3..0289c9fac02b 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -49,8 +49,8 @@ 'is_lost': False, 'last_lost_timestamp': datetime.datetime(1970, 1, 1, 3, 0, tzinfo=datetime.timezone.utc), 'last_timestamp': datetime.datetime(2020, 8, 13, 0, 55, 26, tzinfo=datetime.timezone.utc), - : 1, - : 1, + : 1, + : 1, 'ring_state': 'STOPPED', : , : , diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr index 6ec0373decfa..bfab86d92e01 100644 --- a/tests/components/tractive/snapshots/test_device_tracker.ambr +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 99, : list([ ]), - : 22.333, - : 44.555, + : 22.333, + : 44.555, : , : , }), diff --git a/tests/components/volvo/snapshots/test_device_tracker.ambr b/tests/components/volvo/snapshots/test_device_tracker.ambr index 0eaad5ec01bb..9ec7def8ef50 100644 --- a/tests/components/volvo/snapshots/test_device_tracker.ambr +++ b/tests/components/volvo/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), @@ -104,8 +104,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), @@ -163,8 +163,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), @@ -222,8 +222,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), From 798888125a13838bce8a15b7b5f81fd9738334d5 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:50:15 +0800 Subject: [PATCH 446/707] Add dynamic icon for lightbulb group (#176218) --- homeassistant/components/group/icons.json | 10 ++++++++++ homeassistant/components/group/light.py | 2 +- tests/components/group/test_config_flow.py | 1 - 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/group/icons.json b/homeassistant/components/group/icons.json index e3084bf950d6..f79b1e3b24f7 100644 --- a/homeassistant/components/group/icons.json +++ b/homeassistant/components/group/icons.json @@ -1,4 +1,14 @@ { + "entity": { + "light": { + "light": { + "default": "mdi:lightbulb-group", + "state": { + "off": "mdi:lightbulb-group-off" + } + } + } + }, "services": { "reload": { "service": "mdi:reload" diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index ace3878655a3..68f922272b4c 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -147,7 +147,7 @@ class LightGroup(GroupEntity, LightEntity): """Representation of a light group.""" _attr_available = False - _attr_icon = "mdi:lightbulb-group" + _attr_translation_key = "light" _attr_max_color_temp_kelvin = 6500 _attr_min_color_temp_kelvin = 2000 _attr_should_poll = False diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 8381079c728a..66d43dd74948 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -475,7 +475,6 @@ EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] FAN_ATTRS = [{"supported_features": 0}, {}] LIGHT_ATTRS = [ { - "icon": "mdi:lightbulb-group", "supported_color_modes": ["onoff"], "supported_features": 0, }, From f5accf6f0261ff58bc6de596fac6d85e42fea06a Mon Sep 17 00:00:00 2001 From: Nitay Ben-Zvi Date: Fri, 10 Jul 2026 21:31:22 +0300 Subject: [PATCH 447/707] homekit: add native HeaterCooler accessory (#148231) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/homekit/__init__.py | 49 +- .../components/homekit/accessories.py | 186 +- .../components/homekit/aidmanager.py | 91 +- .../components/homekit/climate_base.py | 82 +- .../components/homekit/climate_util.py | 20 +- .../components/homekit/config_flow.py | 174 +- homeassistant/components/homekit/const.py | 6 + homeassistant/components/homekit/strings.json | 21 +- .../components/homekit/type_heater_coolers.py | 734 ++++ .../components/homekit/type_thermostats.py | 5 +- homeassistant/components/homekit/util.py | 20 + tests/components/homekit/test_accessories.py | 47 +- .../components/homekit/test_accessory_type.py | 399 +++ tests/components/homekit/test_aidmanager.py | 155 + tests/components/homekit/test_config_flow.py | 243 +- .../homekit/test_get_accessories.py | 243 +- .../homekit/test_type_heater_coolers.py | 3113 +++++++++++++++++ .../homekit/test_type_thermostats.py | 19 +- tests/components/homekit/test_util.py | 9 + 19 files changed, 5517 insertions(+), 99 deletions(-) create mode 100644 homeassistant/components/homekit/type_heater_coolers.py create mode 100644 tests/components/homekit/test_accessory_type.py create mode 100644 tests/components/homekit/test_type_heater_coolers.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 0626071eeaff..55f2ae2d3bc8 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -88,6 +88,7 @@ from . import ( # noqa: F401 type_cameras, type_covers, type_fans, + type_heater_coolers, type_humidifiers, type_lights, type_locks, @@ -98,7 +99,13 @@ from . import ( # noqa: F401 type_switches, type_thermostats, ) -from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory +from .accessories import ( + HomeAccessory, + HomeBridge, + HomeDriver, + async_resolve_accessory_type, + get_accessory, +) from .aidmanager import AccessoryAidStorage from .const import ( ATTR_INTEGRATION, @@ -572,6 +579,10 @@ class HomeKit: self.bridge: HomeBridge | None = None self._reset_lock = asyncio.Lock() self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None + # True while running the first ever start of this entry (no + # persisted pairing state yet); accessory mode uses it to tell a + # brand new entry from one that predates the HeaterCooler. + self._first_ever_start = False def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> bool: """Set up bridge and accessory driver. @@ -669,8 +680,10 @@ class HomeKit: removed: list[str] = [] acc: HomeAccessory | None for entity_id in entity_ids: - aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - if aid not in self.bridge.accessories: + # A lookup must not allocate; an allocation marks the entity as + # previously bridged, which would suppress the automatic routing. + aid = self.aid_storage.get_allocated_aid_for_entity_id(entity_id) + if aid is None or aid not in self.bridge.accessories: continue if acc := self.async_remove_bridge_accessory(aid): self._async_shutdown_accessory(acc) @@ -753,8 +766,14 @@ class HomeKit: assert self.aid_storage is not None assert self.bridge is not None - aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) conf = self._config.get(state.entity_id, {}).copy() + # Must run before the aid is allocated below so a never bridged + # entity is still recognizable as new. + pending_type = async_resolve_accessory_type( + self.aid_storage, state, conf, allow_auto=True + ) + newly_allocated = not self.aid_storage.entity_is_allocated(state.entity_id) + aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) # If an accessory cannot be created or added due to an exception # of any kind (usually in pyhap) it should not prevent # the rest of the accessories from being created @@ -762,11 +781,19 @@ class HomeKit: acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) + if pending_type: + self.aid_storage.async_set_accessory_type( + state.entity_id, pending_type + ) return acc except Exception: _LOGGER.exception( "Failed to create a HomeKit accessory for %s", state.entity_id ) + if newly_allocated: + # A failed first attempt must not classify the entity as + # existing on the next try. + self.aid_storage.async_delete_aid_for_entity_id(state.entity_id) return None def _would_exceed_max_devices(self, name: str | None) -> bool: @@ -882,6 +909,7 @@ class HomeKit: self.setup, async_zc_instance, uuid ) assert self.driver is not None + self._first_ever_start = not loaded_from_disk if not await self._async_create_accessories(): return @@ -894,6 +922,9 @@ class HomeKit: # need to make sure its persisted to disk. async with self.hass.data[PERSIST_LOCK_DATA]: await self.hass.async_add_executor_job(self.driver.persist) + # The pairing state is persisted now, so later reloads treat the + # entry as existing. + self._first_ever_start = False self.status = STATUS_RUNNING if self.driver.state.paired: @@ -1000,6 +1031,13 @@ class HomeKit: return None state = entity_states[0] conf = self._config.get(state.entity_id, {}).copy() + # Accessory mode has no aid allocation to tell new from existing, + # so only a brand new pairing picks its type automatically; anything + # else keeps its current accessory. + assert self.aid_storage is not None + pending_type = async_resolve_accessory_type( + self.aid_storage, state, conf, allow_auto=self._first_ever_start + ) acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) if acc is None: _LOGGER.error( @@ -1007,6 +1045,9 @@ class HomeKit: self._name, self._filter.config, ) + return None + if pending_type: + self.aid_storage.async_set_accessory_type(state.entity_id, pending_type) return acc async def _async_create_bridge_accessory( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 186be4ce8c0c..5215c4128cb7 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -12,6 +12,10 @@ from pyhap.iid_manager import IIDManager from pyhap.service import Service from pyhap.util import callback as pyhap_callback +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityFeature, +) from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass @@ -56,6 +60,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.decorator import Registry +from .aidmanager import AccessoryAidStorage +from .climate_util import ( + get_fan_modes_and_speeds, + get_swing_on_mode, + has_swing_off_mode, +) from .const import ( ATTR_DISPLAY_NAME, ATTR_INTEGRATION, @@ -87,10 +97,12 @@ from .const import ( TYPE_AIR_PURIFIER, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from .iidmanager import AccessoryIIDStorage @@ -117,6 +129,10 @@ FAN_TYPES = { TYPE_AIR_PURIFIER: "AirPurifier", TYPE_FAN: "Fan", } +CLIMATE_TYPES = { + TYPE_HEATER_COOLER: "HeaterCooler", + TYPE_THERMOSTAT: "Thermostat", +} TYPES: Registry[str, type[HomeAccessory]] = Registry() RELOAD_ON_CHANGE_ATTRS = ( @@ -126,6 +142,94 @@ RELOAD_ON_CHANGE_ATTRS = ( ) +def climate_controls_target_humidity(state: State) -> bool: + """Return True when a climate entity exposes a humidity setpoint. + + HeaterCooler cannot control a humidity setpoint; entities that + expose one (e.g. econet) stay on the Thermostat, which can. + """ + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + return bool(features & ClimateEntityFeature.TARGET_HUMIDITY) + + +def climate_supports_heater_cooler(state: State) -> bool: + """Return True when a climate entity fits the HeaterCooler accessory.""" + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + # Timing fan modes like auto or circulate do not count as speeds. + has_fan = bool(features & ClimateEntityFeature.FAN_MODE) and ( + len(get_fan_modes_and_speeds(attributes)[1]) >= 2 + ) + # The binary swing control writes the off mode back, so automatic + # routing requires the entity to advertise one. + has_swing = bool(features & ClimateEntityFeature.SWING_MODE) and ( + get_swing_on_mode(attributes) is not None and has_swing_off_mode(attributes) + ) + return (has_fan or has_swing) and not ( + features & ClimateEntityFeature.TARGET_HUMIDITY + ) + + +@ha_callback +def async_resolve_accessory_type( + aid_storage: AccessoryAidStorage, + state: State, + conf: dict[str, Any], + *, + allow_auto: bool, +) -> str | None: + """Resolve which accessory an entity uses into conf. + + Some domains can be represented by more than one HomeKit accessory; + climate is the only such domain today. Returns the accessory type the + caller must record with async_set_accessory_type once the accessory is + successfully created, so a failed creation is not sticky across + restarts; a stored routing the entity can no longer support is dropped + immediately instead. + """ + if state.domain != CLIMATE_DOMAIN: + return None + return _async_resolve_climate_type(aid_storage, state, conf, allow_auto=allow_auto) + + +@ha_callback +def _async_resolve_climate_type( + aid_storage: AccessoryAidStorage, + state: State, + conf: dict[str, Any], + *, + allow_auto: bool, +) -> str | None: + """Resolve which accessory a climate entity uses into conf. + + An explicit type in the entity config always wins, even for entities + with a humidity setpoint, and updates the stored routing, so switching + back to automatic keeps the accessory the entity already uses. In + bridge mode an entity that has never been bridged gets the HeaterCooler + when capable. Anything else keeps the Thermostat; the accessory type + can be changed at any time from the bridge options. + """ + entity_id = state.entity_id + if climate_type := conf.get(CONF_TYPE): + # The explicit type is recorded by the caller like the automatic + # one, so every path that sets a type defers persistence until + # the accessory exists. + return cast(str, climate_type) + if aid_storage.get_accessory_type(entity_id) == TYPE_HEATER_COOLER: + if not climate_controls_target_humidity(state): + conf[CONF_TYPE] = TYPE_HEATER_COOLER + return None + # A humidity setpoint gained since the choice was stored cannot + # be represented by the HeaterCooler, so the routing is dropped. + aid_storage.async_set_accessory_type(entity_id, None) + if not climate_supports_heater_cooler(state): + return None + if allow_auto and not aid_storage.entity_is_allocated(entity_id): + conf[CONF_TYPE] = TYPE_HEATER_COOLER + return TYPE_HEATER_COOLER + return None + + def get_accessory( # noqa: C901 hass: HomeAssistant, driver: HomeDriver, state: State, aid: int | None, config: dict ) -> HomeAccessory | None: @@ -151,7 +255,8 @@ def get_accessory( # noqa: C901 a_type = "BinarySensor" elif state.domain == "climate": - a_type = "Thermostat" + # The type is resolved by the bridge before the accessory is created. + a_type = CLIMATE_TYPES[config.get(CONF_TYPE, TYPE_THERMOSTAT)] elif state.domain == "cover": device_class = state.attributes.get(ATTR_DEVICE_CLASS) @@ -637,6 +742,25 @@ class HomeAccessory(Accessory): # type: ignore[misc] value: Any | None = None, ) -> None: """Fire event and call service for changes from HomeKit.""" + self.hass.async_create_task( + self.async_call_service_and_wait(domain, service, service_data, value), + eager_start=True, + ) + + async def async_call_service_and_wait( + self, + domain: str, + service: str, + service_data: dict[str, Any], + value: Any | None = None, + ) -> bool: + """Fire event and call service, returning True when it succeeded. + + blocking=True so the handler's exception reaches us (the + non-blocking path swallows it); on failure we resync so pyhap's + optimistic target characteristic doesn't strand the tile on the + requested action. + """ event_data = { ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id), ATTR_DISPLAY_NAME: self.display_name, @@ -647,36 +771,40 @@ class HomeAccessory(Accessory): # type: ignore[misc] self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data, context=context) - async def _call() -> None: - # blocking=True so the handler's exception reaches us (the - # non-blocking path swallows it); on failure we resync so pyhap's - # optimistic target characteristic doesn't strand the tile on the - # requested action. - try: - await self.hass.services.async_call( - domain, service, service_data, blocking=True, context=context - ) - except HomeAssistantError as err: - _LOGGER.warning( - "%s: %s.%s failed (%s); re-syncing HomeKit state", - self.entity_id, - domain, - service, - err, - ) - except Exception: - _LOGGER.exception( - "%s: %s.%s raised unexpectedly; re-syncing HomeKit state", - self.entity_id, - domain, - service, - ) - else: - return + try: + await self.hass.services.async_call( + domain, service, service_data, blocking=True, context=context + ) + except HomeAssistantError as err: + _LOGGER.warning( + "%s: %s.%s failed (%s); re-syncing HomeKit state", + self.entity_id, + domain, + service, + err, + ) + except Exception: + _LOGGER.exception( + "%s: %s.%s raised unexpectedly; re-syncing HomeKit state", + self.entity_id, + domain, + service, + ) + else: + return True + # This coroutine often runs fire-and-forget, so failures must be + # logged here instead of by the loop's default task handler. + try: if (state := self.hass.states.get(self.entity_id)) is not None: self.async_update_state(state) - - self.hass.async_create_task(_call(), eager_start=True) + else: + _LOGGER.debug( + "%s: cannot re-sync HomeKit state; entity has no state", + self.entity_id, + ) + except Exception: + _LOGGER.exception("%s: re-syncing HomeKit state failed", self.entity_id) + return False @ha_callback def async_reload(self) -> None: diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index c76232f65f91..abcfb03575cd 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -25,6 +25,7 @@ AID_MANAGER_SAVE_DELAY = 2 ALLOCATIONS_KEY = "allocations" UNIQUE_IDS_KEY = "unique_ids" +ACCESSORY_TYPES_KEY = "accessory_types" INVALID_AIDS = (0, 1) @@ -69,6 +70,7 @@ class AccessoryAidStorage: self.hass = hass self.allocations: dict[str, int] = {} self.allocated_aids: set[int] = set() + self.accessory_types: dict[str, str] = {} self._entry_id = entry_id self.store: Store | None = None self._entity_registry = er.async_get(hass) @@ -84,6 +86,59 @@ class AccessoryAidStorage: assert isinstance(raw_storage, dict) self.allocations = raw_storage.get(ALLOCATIONS_KEY, {}) self.allocated_aids = set(self.allocations.values()) + self.accessory_types = raw_storage.get(ACCESSORY_TYPES_KEY, {}) + + def _stable_storage_keys(self, entity_id: str) -> tuple[str, ...]: + """Return the keys the entity's stable identity can resolve to. + + The preferred key comes first, matching the aid allocation + preference for the system unique id over the entity id. + """ + if not (entry := self._entity_registry.async_get(entity_id)): + return (entity_id,) + keys = [get_system_unique_id(entry, entry.unique_id)] + if previous_unique_id := entry.previous_unique_id: + keys.append(get_system_unique_id(entry, previous_unique_id)) + keys.append(entity_id) + return tuple(keys) + + @callback + def async_set_accessory_type( + self, entity_id: str, accessory_type: str | None + ) -> None: + """Persist the accessory type an entity resolved to, None clears it. + + The choice is stored by the same stable identity as the aid + allocation, so it survives entity id renames and unique id changes. + """ + if accessory_type == self.get_accessory_type(entity_id): + return + types = self.accessory_types + keys = self._stable_storage_keys(entity_id) + for key in keys: + types.pop(key, None) + if accessory_type is not None: + types[keys[0]] = accessory_type + self.async_schedule_save() + + @callback + def get_accessory_type(self, entity_id: str) -> str | None: + """Return the stored accessory type for the entity, if any. + + A type found under an outdated identity moves to the current one + and schedules a save, since only the latest previous unique id + stays resolvable; the read is loop bound because of that. + """ + types = self.accessory_types + keys = self._stable_storage_keys(entity_id) + for key in keys: + if (accessory_type := types.get(key)) is not None: + if key != keys[0]: + del types[key] + types[keys[0]] = accessory_type + self.async_schedule_save() + return accessory_type + return None def get_or_allocate_aid_for_entity_id(self, entity_id: str) -> int: """Generate a stable aid for an entity id.""" @@ -94,6 +149,15 @@ class AccessoryAidStorage: self._migrate_unique_id_aid_assignment_if_needed(sys_unique_id, entry) return self.get_or_allocate_aid(sys_unique_id, entity_id) + def entity_is_allocated(self, entity_id: str) -> bool: + """Return True when the entity already has an allocated aid. + + Checks every key get_or_allocate_aid_for_entity_id could resolve to, + without allocating, so callers can tell a previously bridged entity + from a new one. + """ + return self.get_allocated_aid_for_entity_id(entity_id) is not None + def _migrate_unique_id_aid_assignment_if_needed( self, sys_unique_id: str, entry: er.RegistryEntry ) -> None: @@ -129,6 +193,24 @@ class AccessoryAidStorage: f"Unable to generate unique aid allocation for {entity_id} [{unique_id}]" ) + def get_allocated_aid_for_entity_id(self, entity_id: str) -> int | None: + """Return the entity's allocated aid without allocating one.""" + allocations = self.allocations + return next( + ( + allocations[key] + for key in self._stable_storage_keys(entity_id) + if key in allocations + ), + None, + ) + + @callback + def async_delete_aid_for_entity_id(self, entity_id: str) -> None: + """Remove the aid allocation for an entity.""" + for key in self._stable_storage_keys(entity_id): + self.delete_aid(key) + def delete_aid(self, storage_key: str) -> None: """Delete an aid allocation.""" if storage_key not in self.allocations: @@ -150,6 +232,11 @@ class AccessoryAidStorage: return await self.store.async_save(self._data_to_save()) @callback - def _data_to_save(self) -> dict[str, dict[str, int]]: + def _data_to_save(self) -> dict[str, dict[str, int] | dict[str, str]]: """Return data of entity map to store in a file.""" - return {ALLOCATIONS_KEY: self.allocations} + data: dict[str, dict[str, int] | dict[str, str]] = { + ALLOCATIONS_KEY: self.allocations + } + if self.accessory_types: + data[ACCESSORY_TYPES_KEY] = self.accessory_types + return data diff --git a/homeassistant/components/homekit/climate_base.py b/homeassistant/components/homekit/climate_base.py index 93a3b8b6fa4f..401f5409d0d8 100644 --- a/homeassistant/components/homekit/climate_base.py +++ b/homeassistant/components/homekit/climate_base.py @@ -33,7 +33,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import State, callback from homeassistant.util.percentage import percentage_to_ordered_list_item @@ -45,6 +50,7 @@ from .climate_util import ( get_swing_off_mode, get_swing_on_mode, get_temperature_range_from_state, + has_swing_off_mode, is_swing_on, resolve_target_temp_range, temperature_attribute_to_homekit, @@ -69,6 +75,9 @@ FAN_STATE_INACTIVE = 0 FAN_STATE_IDLE = 1 FAN_STATE_ACTIVE = 2 +# States in which a climate entity is inactive rather than idle +CLIMATE_INACTIVE_STATES = frozenset({HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN}) + HC_HASS_TO_HOMEKIT_FAN_STATE = { HVACAction.OFF: FAN_STATE_INACTIVE, HVACAction.IDLE: FAN_STATE_IDLE, @@ -116,7 +125,11 @@ class HomeKitClimateAccessory(HomeAccessory): self.swing_on_mode: str | None = None self.swing_off_mode: str = SWING_OFF - if features & ClimateEntityFeature.SWING_MODE: + # The binary swing toggle writes the off mode back, so it is only + # usable when the entity advertises one. + if features & ClimateEntityFeature.SWING_MODE and has_swing_off_mode( + attributes + ): self.swing_on_mode = get_swing_on_mode(attributes) self.swing_off_mode = get_swing_off_mode(attributes) @@ -165,6 +178,20 @@ class HomeKitClimateAccessory(HomeAccessory): char.allow_invalid_client_values = True return char + def _reject_char_write(self, char: Characteristic, value: Any) -> None: + """Flip a characteristic back after rejecting a client write.""" + char.value = value + char.notify() + + def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None: + """Send a climate write from a characteristic setter. + + Subclasses can override this to serialize their writes. + """ + self.async_call_service( + CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params} + ) + def _update_temperature_char( self, char: Characteristic, state: State, attr: str ) -> None: @@ -213,29 +240,32 @@ class HomeKitClimateAccessory(HomeAccessory): """Convert a temperature in the HomeKit unit to the entity's unit.""" return temperature_to_states(temp, self._unit) - def _set_fan_speed(self, speed: int) -> None: - """Send the climate fan mode for a HomeKit rotation speed.""" + def _fan_speed_params(self, speed: int) -> dict[str, Any] | None: + """Return the set_fan_mode data for a HomeKit rotation speed.""" _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) if not self.ordered_fan_speeds or not 0 < speed <= 100: - return + return None mode = fan_speed_to_mode(self.ordered_fan_speeds, self.fan_modes, speed) - self.async_call_service( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode}, - ) + return {ATTR_FAN_MODE: mode} + + def _set_fan_speed(self, speed: int) -> None: + """Send the climate fan mode for a HomeKit rotation speed.""" + if (params := self._fan_speed_params(speed)) is not None: + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, params) + + def _swing_mode_params(self, swing_on: int) -> dict[str, Any] | None: + """Return the set_swing_mode data for a HomeKit swing toggle.""" + if self.swing_on_mode is None: + return None + _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) + return { + ATTR_SWING_MODE: self.swing_on_mode if swing_on else self.swing_off_mode + } def _set_swing_mode(self, swing_on: int) -> None: """Send the climate swing mode for a HomeKit swing toggle.""" - if self.swing_on_mode is None: - return - _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) - mode = self.swing_on_mode if swing_on else self.swing_off_mode - self.async_call_service( - CLIMATE_DOMAIN, - SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode}, - ) + if (params := self._swing_mode_params(swing_on)) is not None: + self._dispatch_climate_write(SERVICE_SET_SWING_MODE, params) def _update_fan_speed_char(self, attributes: Mapping[str, Any]) -> None: """Update the rotation speed characteristic from the current fan mode.""" @@ -310,12 +340,10 @@ class HomeKitClimateAccessory(HomeAccessory): _LOGGER.debug( "%s: Fan does not support off, resetting to on", self.entity_id ) - self.char_fan_active.value = 1 - self.char_fan_active.notify() + self._reject_char_write(self.char_fan_active, 1) return mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode}) def _set_fan_auto(self, auto: int) -> None: """Send the climate fan mode for a HomeKit fan auto toggle. @@ -325,8 +353,7 @@ class HomeKitClimateAccessory(HomeAccessory): """ _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode}) @callback def _async_update_fan_service(self, new_state: State) -> None: @@ -349,5 +376,8 @@ class HomeKitClimateAccessory(HomeAccessory): ) self.char_fan_active.set_value( - int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF) + int( + new_state.state not in CLIMATE_INACTIVE_STATES + and fan_mode_lower != FAN_OFF + ) ) diff --git a/homeassistant/components/homekit/climate_util.py b/homeassistant/components/homekit/climate_util.py index 6731217fa1b6..92a5bfce06b8 100644 --- a/homeassistant/components/homekit/climate_util.py +++ b/homeassistant/components/homekit/climate_util.py @@ -1,6 +1,7 @@ """Shared fan, swing, and temperature helpers for the climate accessory types.""" from collections.abc import Iterable +import math from typing import Any from homeassistant.components.climate import ( @@ -85,6 +86,12 @@ def get_swing_off_mode(attributes: dict[str, Any]) -> str: return _lower_to_original(swing_modes).get(SWING_OFF, SWING_OFF) +def has_swing_off_mode(attributes: dict[str, Any]) -> bool: + """Return whether the entity advertises a swing off mode.""" + swing_modes = attributes.get(ATTR_SWING_MODES) or [] + return SWING_OFF in _lower_to_original(swing_modes) + + def fan_speed_to_mode( ordered_fan_speeds: list[str], fan_modes: dict[str, str], speed: int ) -> str: @@ -125,18 +132,27 @@ def get_temperature_range_from_state( because the Home app crashes on negative bounds. """ if (min_temp := state.attributes.get(ATTR_MIN_TEMP)) is not None: - min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 + min_temp = temperature_to_homekit(min_temp, unit) else: min_temp = default_min if (max_temp := state.attributes.get(ATTR_MAX_TEMP)) is not None: - max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2 + max_temp = temperature_to_homekit(max_temp, unit) else: max_temp = default_max # Handle a reversed temperature range min_temp, max_temp = get_min_max(min_temp, max_temp) + # Round inward to the characteristic's 0.1 step so the slider cannot + # produce a write the entity's own limit validation rejects; a range + # too narrow to hold a step keeps the exact limits rather than + # expanding beyond them. + rounded_min = math.ceil(min_temp * 10) / 10 + rounded_max = math.floor(max_temp * 10) / 10 + if rounded_min <= rounded_max: + min_temp, max_temp = rounded_min, rounded_max + min_temp = max(min_temp, 0) max_temp = max(max_temp, min_temp) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 0d3294a1365e..fe50616a732b 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import device_automation from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN @@ -31,6 +32,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_NAME, CONF_PORT, + CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import ( @@ -55,14 +57,24 @@ from .const import ( HOMEKIT_MODE_BRIDGE, HOMEKIT_MODES, SHORT_BRIDGE_NAME, + TYPE_HEATER_COOLER, + TYPE_THERMOSTAT, VIDEO_CODEC_COPY, ) +from .models import HomeKitEntryData from .util import async_find_next_available_port, state_needs_accessory_mode CONF_CAMERA_AUDIO = "camera_audio" CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" +CLIMATE_TYPE_AUTOMATIC = "automatic" +# Display names for the accessory classes a climate entity can use +CLIMATE_ACCESSORY_NAMES = { + "Thermostat": "Thermostat", + "HeaterCooler": "Heater Cooler", +} + MODE_INCLUDE = "include" MODE_EXCLUDE = "exclude" @@ -179,14 +191,29 @@ def _async_build_entities_filter( ) -def _async_cameras_from_entities(entities: list[str]) -> list[str]: +def _async_entities_in_domain(entities: list[str], domain: str) -> list[str]: return [ - entity_id - for entity_id in entities - if entity_id.startswith(CAMERA_ENTITY_PREFIX) + entity_id for entity_id in entities if split_entity_id(entity_id)[0] == domain ] +@callback +def _async_included_domain_entities( + hass: HomeAssistant, + entity_filter: EntityFilterDict, + entities: list[str], + domain: str, +) -> list[str]: + """Return a domain's included entities, expanding a whole domain include. + + The whole domain is included when none of its entities are selected + explicitly. + """ + if domain in entity_filter[CONF_INCLUDE_DOMAINS]: + return _async_get_matching_entities(hass, [domain]) + return _async_entities_in_domain(entities, domain) + + async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: """Create a mapping of types of devices/entities HomeKit can support.""" integrations = await async_get_integrations(hass, SUPPORTED_DOMAINS) @@ -374,6 +401,97 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.hk_options: dict[str, Any] = {} self.included_cameras: list[str] = [] + self.included_climates: list[str] = [] + # Maps the displayed climate field label back to its entity id. + self._climate_choices: dict[str, str] = {} + + async def async_step_climate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose the accessory type for climate entities.""" + if not self.included_climates: + return await self.async_step_bridged_device_triggers() + + hk_options = self.hk_options + all_entity_config: dict[str, dict[str, Any]] + + if user_input is not None: + all_entity_config = hk_options[CONF_ENTITY_CONFIG] + for label, entity_id in self._climate_choices.items(): + entity_config = all_entity_config.setdefault(entity_id, {}) + + if (choice := user_input[label]) == CLIMATE_TYPE_AUTOMATIC: + entity_config.pop(CONF_TYPE, None) + else: + entity_config[CONF_TYPE] = choice + + if not entity_config: + all_entity_config.pop(entity_id) + + if not all_entity_config: + del hk_options[CONF_ENTITY_CONFIG] + + return await self.async_step_bridged_device_triggers() + + # Field labels come from the schema keys, so key the form by the + # friendly name and map back to the entity id on submit. The + # accessory a bridged entity currently uses is shown so Automatic + # is not a mystery. + current_accessories = self._async_current_climate_accessories() + self._climate_choices = {} + for entity_id in self.included_climates: + state = self.hass.states.get(entity_id) + label = f"{state.name} ({entity_id})" if state else entity_id + if current := current_accessories.get(entity_id): + label = f"{label} [{current}]" + self._climate_choices[label] = entity_id + + all_entity_config = hk_options.setdefault(CONF_ENTITY_CONFIG, {}) + type_selector = selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + CLIMATE_TYPE_AUTOMATIC, + TYPE_THERMOSTAT, + TYPE_HEATER_COOLER, + ], + translation_key="climate_accessory_type", + ) + ) + data_schema = vol.Schema( + { + vol.Required( + label, + default=all_entity_config.get(entity_id, {}).get( + CONF_TYPE, CLIMATE_TYPE_AUTOMATIC + ), + ): type_selector + for label, entity_id in self._climate_choices.items() + } + ) + return self.async_show_form(step_id="climate", data_schema=data_schema) + + @callback + def _async_current_climate_accessories(self) -> dict[str, str]: + """Map bridged climate entities to their current accessory name.""" + entry_data: HomeKitEntryData | None = getattr( + self.config_entry, "runtime_data", None + ) + if entry_data is None: + return {} + homekit = entry_data.homekit + accessories: Iterable[Any] + if homekit.bridge is not None: + accessories = homekit.bridge.accessories.values() + elif homekit.driver is not None and homekit.driver.accessory is not None: + accessories = [homekit.driver.accessory] + else: + return {} + return { + entity_id: CLIMATE_ACCESSORY_NAMES[accessory_name] + for accessory in accessories + if (entity_id := getattr(accessory, "entity_id", None)) is not None + and (accessory_name := type(accessory).__name__) in CLIMATE_ACCESSORY_NAMES + } async def async_step_yaml( self, user_input: dict[str, Any] | None = None @@ -426,6 +544,9 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose camera config.""" + if not self.included_cameras: + return await self.async_step_climate() + hk_options = self.hk_options all_entity_config: dict[str, dict[str, Any]] @@ -447,7 +568,7 @@ class OptionsFlowHandler(OptionsFlow): if not entity_config: all_entity_config.pop(entity_id) - return await self.async_step_bridged_device_triggers() + return await self.async_step_climate() cameras_with_audio = [] cameras_with_copy = [] @@ -492,11 +613,10 @@ class OptionsFlowHandler(OptionsFlow): if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) entity_filter = _async_build_entities_filter(domains, entities) - self.included_cameras = _async_cameras_from_entities(entities) + self.included_cameras = _async_entities_in_domain(entities, CAMERA_DOMAIN) + self.included_climates = _async_entities_in_domain(entities, CLIMATE_DOMAIN) hk_options[CONF_FILTER] = entity_filter - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + return await self.async_step_cameras() entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) @@ -536,13 +656,17 @@ class OptionsFlowHandler(OptionsFlow): domains = hk_options[CONF_DOMAINS] if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) - self.included_cameras = _async_cameras_from_entities(entities) - hk_options[CONF_FILTER] = _async_build_entities_filter(domains, entities) - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + entity_filter = _async_build_entities_filter(domains, entities) + self.included_cameras = _async_included_domain_entities( + self.hass, entity_filter, entities, CAMERA_DOMAIN + ) + self.included_climates = _async_included_domain_entities( + self.hass, entity_filter, entities, CLIMATE_DOMAIN + ) + hk_options[CONF_FILTER] = entity_filter + return await self.async_step_cameras() - entity_filter: EntityFilterDict = hk_options.get(CONF_FILTER, {}) + entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) all_supported_entities = _async_get_matching_entities( self.hass, domains, include_entity_category=True, include_hidden=True @@ -579,23 +703,23 @@ class OptionsFlowHandler(OptionsFlow): domains = hk_options[CONF_DOMAINS] if user_input is not None: - self.included_cameras = [] entities = cv.ensure_list(user_input[CONF_ENTITIES]) - if CAMERA_DOMAIN in domains: - camera_entities = _async_get_matching_entities( - self.hass, [CAMERA_DOMAIN] - ) - self.included_cameras = [ + + def _remaining_in_domain(domain: str) -> list[str]: + if domain not in domains: + return [] + return [ entity_id - for entity_id in camera_entities + for entity_id in _async_get_matching_entities(self.hass, [domain]) if entity_id not in entities ] + + self.included_cameras = _remaining_in_domain(CAMERA_DOMAIN) + self.included_climates = _remaining_in_domain(CLIMATE_DOMAIN) hk_options[CONF_FILTER] = _make_entity_filter( include_domains=domains, exclude_entities=entities ) - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + return await self.async_step_cameras() entity_filter = self.hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 5d5a8efc0e2a..0f69c4350f27 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -128,6 +128,9 @@ TYPE_VALVE = "valve" TYPE_FAN = "fan" TYPE_AIR_PURIFIER = "air_purifier" +TYPE_HEATER_COOLER = "heater_cooler" +TYPE_THERMOSTAT = "thermostat" + # #### Categories #### CATEGORY_RECEIVER = 34 @@ -145,6 +148,7 @@ SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_FILTER_MAINTENANCE = "FilterMaintenance" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" +SERV_HEATER_COOLER = "HeaterCooler" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" SERV_INPUT_SOURCE = "InputSource" @@ -193,6 +197,7 @@ CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_FAN_STATE = "CurrentFanState" +CHAR_CURRENT_HEATER_COOLER_STATE = "CurrentHeaterCoolerState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER = "CurrentHumidifierDehumidifierState" CHAR_CURRENT_POSITION = "CurrentPosition" @@ -245,6 +250,7 @@ CHAR_STREAMING_STRATUS = "StreamingStatus" CHAR_SWING_MODE = "SwingMode" CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState" CHAR_TARGET_DOOR_STATE = "TargetDoorState" +CHAR_TARGET_HEATER_COOLER_STATE = "TargetHeaterCoolerState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" CHAR_TARGET_FAN_STATE = "TargetFanState" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 3bb8625cba5d..6c93b3f65356 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -5,7 +5,7 @@ }, "step": { "pairing": { - "description": "To complete pairing follow the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d.", + "description": "To complete pairing follow the instructions in “Notifications” under “HomeKit Pairing”.", "title": "Pair HomeKit" }, "user": { @@ -40,18 +40,22 @@ "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single-board computers.", "title": "Camera configuration" }, + "climate": { + "description": "Choose which accessory each climate entity uses in HomeKit. Heater Cooler puts the mode, temperature, and the supported fan speed and swing controls on one air conditioner style tile; Thermostat is the classic temperature dial with a separate fan. Automatic keeps the accessory the entity already uses and picks the best fit when it is bridged for the first time. Changing the accessory type keeps the room and name, but HomeKit scenes or automations that used the old controls may need to be recreated.", + "title": "Climate accessory configuration" + }, "exclude": { "data": { "entities": "[%key:component::homekit::options::step::include::data::entities%]" }, - "description": "All \u201c{domains}\u201d entities will be included except for the excluded entities and categorized entities.", + "description": "All “{domains}” entities will be included except for the excluded entities and categorized entities.", "title": "Select the entities to be excluded" }, "include": { "data": { "entities": "Entities" }, - "description": "Select entities from each domain in \u201c{domains}\u201d. The include will cover the entire domain if you do not select any entities for a given domain.", + "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.", "title": "Select the entities to be included" }, "init": { @@ -60,7 +64,7 @@ "include_exclude_mode": "Inclusion mode", "mode": "HomeKit mode" }, - "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the “Domains to include” will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "yaml": { @@ -69,6 +73,15 @@ } } }, + "selector": { + "climate_accessory_type": { + "options": { + "automatic": "Automatic", + "heater_cooler": "Heater Cooler", + "thermostat": "Thermostat" + } + } + }, "services": { "reload": { "description": "Reloads HomeKit and re-processes the YAML-configuration.", diff --git a/homeassistant/components/homekit/type_heater_coolers.py b/homeassistant/components/homekit/type_heater_coolers.py new file mode 100644 index 000000000000..64f726911985 --- /dev/null +++ b/homeassistant/components/homekit/type_heater_coolers.py @@ -0,0 +1,734 @@ +"""Class to hold all heater cooler accessories.""" + +import asyncio +from collections.abc import Callable, Coroutine +import functools +import logging +from typing import Any, Concatenate, NamedTuple, override + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_AIR_CONDITIONER, CATEGORY_HEATER + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_ON, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.core import State, callback +from homeassistant.util.enum import try_parse_enum + +from .accessories import TYPES +from .climate_base import CLIMATE_INACTIVE_STATES, HomeKitClimateAccessory +from .climate_util import temperature_attribute_to_homekit +from .const import ( + CHAR_ACTIVE, + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_FAN_STATE, + CHAR_CURRENT_HEATER_COOLER_STATE, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_NAME, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_HEATER_COOLER, + SERV_HUMIDITY_SENSOR, +) + +_LOGGER = logging.getLogger(__name__) + +# HomeKit CurrentHeaterCoolerState values (per HomeKit spec) +HC_INACTIVE, HC_IDLE, HC_HEATING, HC_COOLING = range(4) + +# HomeKit TargetHeaterCoolerState valid values: Auto=0, Heat=1, Cool=2 +HC_TARGET_AUTO, HC_TARGET_HEAT, HC_TARGET_COOL = range(3) + +# Off is intentionally not mapped: when the entity is off the target +# characteristic keeps the last active mode so it stays in sync with +# _last_known_mode, which is what turning Active back on restores. +HC_HASS_TO_HOMEKIT_TARGET = { + HVACMode.HEAT: HC_TARGET_HEAT, + HVACMode.COOL: HC_TARGET_COOL, + HVACMode.HEAT_COOL: HC_TARGET_AUTO, + HVACMode.AUTO: HC_TARGET_AUTO, +} + +# HomeKit's CurrentHeaterCoolerState has no drying or fan-only value. Those +# actions map to Cooling rather than Idle so the tile still shows the unit is +# doing something, which also matches the Thermostat's action mapping. +HC_HASS_TO_HOMEKIT_ACTION = { + HVACAction.OFF: HC_INACTIVE, + HVACAction.IDLE: HC_IDLE, + HVACAction.HEATING: HC_HEATING, + HVACAction.PREHEATING: HC_HEATING, + HVACAction.COOLING: HC_COOLING, + HVACAction.DRYING: HC_COOLING, + HVACAction.FAN: HC_COOLING, + HVACAction.DEFROSTING: HC_HEATING, +} + +# Hysteresis band in Celsius used when the entity omits hvac_action +ACTION_HYSTERESIS = 0.25 + + +class ClimateServiceCall(NamedTuple): + """A queued climate write and the modes to apply once accepted.""" + + service: str + data: dict[str, Any] + # Remembered as the Active on restore target + commit_mode: HVACMode | None = None + # Bridges resolution until the entity reports a mode change + pending_mode: HVACMode | None = None + + +def _locked_write[**_P]( + func: Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]], +) -> Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]]: + """Run the write coroutine under the accessory's write lock.""" + + @functools.wraps(func) + async def _wrapper(self: HeaterCooler, *args: _P.args, **kwargs: _P.kwargs) -> None: + async with self._write_lock: + await func(self, *args, **kwargs) + + return _wrapper + + +# Modes that drive both a heating and a cooling threshold +RANGE_MODES = (HVACMode.HEAT_COOL, HVACMode.AUTO) + + +@TYPES.register("HeaterCooler") +class HeaterCooler(HomeKitClimateAccessory): + """Generate a HeaterCooler accessory for a climate entity.""" + + # Configured only when the entity accepts a target temperature. + char_cool: Characteristic + char_heat: Characteristic + + # Configured only when the entity reports a current humidity. + char_current_humidity: Characteristic + + def __init__(self, *args: Any) -> None: + """Initialize a HeaterCooler accessory object.""" + super().__init__(*args) + + state = self.hass.states.get(self.entity_id) + assert state + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # The thresholds double as the setpoints, so only expose them when the + # entity accepts a target temperature; a fan/dry-only entity otherwise + # gets sliders that dispatch set_temperature it cannot honor. + has_thresholds = bool( + features + & ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + ) + + hvac_modes = attributes.get(ATTR_HVAC_MODES, []) + current_mode = try_parse_enum(HVACMode, state.state) + + self._supports_off = HVACMode.OFF in hvac_modes + supports_auto = HVACMode.AUTO in hvac_modes or current_mode == HVACMode.AUTO + supports_heat_cool = ( + HVACMode.HEAT_COOL in hvac_modes or current_mode == HVACMode.HEAT_COOL + ) + + can_cool = HVACMode.COOL in hvac_modes or supports_auto or supports_heat_cool + can_heat = HVACMode.HEAT in hvac_modes or supports_auto or supports_heat_cool + + # Standalone pairings advertise the category in the QR code and mDNS + # metadata, so pick the one matching the device instead of Thermostat; + # only a heat only device is a heater, everything else including the + # fan or dry only case is an air conditioner. + self.category = ( + CATEGORY_HEATER if can_heat and not can_cool else CATEGORY_AIR_CONDITIONER + ) + + # Per the HomeKit spec a heater must include the heating threshold and a + # cooler the cooling one, so a one sided device gets a single slider. A + # device with neither side (e.g. dry only with a setpoint) keeps both, + # and a range only device always needs both sides in a write, so the + # setpoints stay controllable. + if (not can_cool and not can_heat) or not ( + features & ClimateEntityFeature.TARGET_TEMPERATURE + ): + can_cool = can_heat = True + self._has_cool_threshold = has_thresholds and can_cool + self._has_heat_threshold = has_thresholds and can_heat + + # Only expose the targets the entity actually supports so HomeKit does + # not offer a mode the climate service would reject. Auto is backed by + # a range mode, preferring HEAT_COOL whose thresholds stay adjustable + # over AUTO, which may follow a schedule, like the thermostat does. + self._hk_to_ha_target: dict[int, HVACMode] = {} + if HVACMode.HEAT in hvac_modes: + self._hk_to_ha_target[HC_TARGET_HEAT] = HVACMode.HEAT + if HVACMode.COOL in hvac_modes: + self._hk_to_ha_target[HC_TARGET_COOL] = HVACMode.COOL + if supports_heat_cool: + self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.HEAT_COOL + elif supports_auto: + self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.AUTO + if not self._hk_to_ha_target: + # Entities exposing neither heat, cool, nor a range mode (e.g. + # fan-only) still need a valid target; map Auto to the first mode the + # entity actually supports so the control does something. A degenerate + # off-only entity has no active mode, so fall back to off rather than + # an unsupported Auto. + fallback_mode = next( + (mode for mode in hvac_modes if mode != HVACMode.OFF), HVACMode.OFF + ) + self._hk_to_ha_target[HC_TARGET_AUTO] = fallback_mode + + chars = [ + CHAR_ACTIVE, + CHAR_CURRENT_HEATER_COOLER_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + CHAR_CURRENT_TEMPERATURE, + ] + if self._has_cool_threshold: + chars.append(CHAR_COOLING_THRESHOLD_TEMPERATURE) + if self._has_heat_threshold: + chars.append(CHAR_HEATING_THRESHOLD_TEMPERATURE) + + # The HeaterCooler service has no auto fan control, so when the entity + # exposes an auto fan mode (and a manual mode to switch back to) the fan + # is exposed through a full linked fan service instead; the rotation + # speed then lives there, since per the HomeKit spec it only belongs on + # the HeaterCooler when the fan cannot be independently controlled. + if FAN_AUTO in self.fan_modes and ( + FAN_ON in self.fan_modes or self.ordered_fan_speeds + ): + self.fan_chars.append(CHAR_TARGET_FAN_STATE) + if self.ordered_fan_speeds: + self.fan_chars.append(CHAR_ROTATION_SPEED) + if attributes.get(ATTR_HVAC_ACTION) is not None: + self.fan_chars.append(CHAR_CURRENT_FAN_STATE) + + # Fan/swing modes are detected in the base class; only advertise the + # characteristics when the entity exposes predefined modes. + if self.ordered_fan_speeds and not self.fan_chars: + chars.append(CHAR_ROTATION_SPEED) + if self.swing_on_mode is not None: + chars.append(CHAR_SWING_MODE) + + serv = self.add_preload_service(SERV_HEATER_COOLER, chars) + + self.char_active = serv.configure_char(CHAR_ACTIVE, value=0) + self.char_current_state = serv.configure_char( + CHAR_CURRENT_HEATER_COOLER_STATE, value=HC_INACTIVE + ) + # Also the reverse lookup for modes only selectable through the + # Auto fallback, so every selectable mode is representable. + self._ha_to_hk_target = { + ha_mode: hk_state for hk_state, ha_mode in self._hk_to_ha_target.items() + } + if HC_TARGET_AUTO in self._hk_to_ha_target: + default_target = HC_TARGET_AUTO + else: + default_target = next(iter(self._hk_to_ha_target)) + self.char_target_state = self._configure_target_mode_char( + serv, + CHAR_TARGET_HEATER_COOLER_STATE, + default_target, + self._ha_to_hk_target, + ) + self._configure_current_temperature_char(serv) + + if self._has_cool_threshold or self._has_heat_threshold: + min_temp_hk, max_temp_hk = self.get_temperature_range(state) + temp_properties = { + PROP_MIN_VALUE: min_temp_hk, + PROP_MAX_VALUE: max_temp_hk, + # We do not set PROP_MIN_STEP here and instead use the HomeKit + # default of 0.1 in order to have enough precision to convert + # temperature units and avoid setting 73F resulting in 74F + } + # Placeholder value within the configured range; async_update_state + # overwrites it from the entity immediately. + default_temp = min(max(21.0, min_temp_hk), max_temp_hk) + if self._has_cool_threshold: + self.char_cool = serv.configure_char( + CHAR_COOLING_THRESHOLD_TEMPERATURE, + value=default_temp, + properties=temp_properties, + ) + if self._has_heat_threshold: + self.char_heat = serv.configure_char( + CHAR_HEATING_THRESHOLD_TEMPERATURE, + value=default_temp, + properties=temp_properties, + ) + + if self.ordered_fan_speeds and not self.fan_chars: + self.char_speed = serv.configure_char( + CHAR_ROTATION_SPEED, + value=100, + properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, + ) + if self.swing_on_mode is not None: + self.char_swing = serv.configure_char(CHAR_SWING_MODE, value=0) + + if self.fan_chars: + self._configure_fan_service(serv) + + # The Heater Cooler service has no humidity characteristic, so surface a + # reported current humidity through a linked humidity sensor. Like the + # Thermostat, this is decided once at setup and not a reload attribute: + # current humidity changes on every update, so reloading on it would + # thrash the accessory. + self._has_humidity = ATTR_CURRENT_HUMIDITY in attributes + if self._has_humidity: + humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME) + serv.add_linked_service(humidity_serv) + humidity_serv.configure_char( + CHAR_NAME, value=f"{self.display_name} Humidity" + ) + self.char_current_humidity = humidity_serv.configure_char( + CHAR_CURRENT_HUMIDITY, value=50 + ) + + # Every service exists now, so they all get an explicit primary + # flag; without one the Home app can pick its own tile service. + self.set_primary_service(serv) + + # Fall back to the displayed target mode so turning Active on for a device + # that was off at startup activates the mode HomeKit is showing rather than + # an arbitrary one. + # Modes without a HomeKit target representation, like dry or fan + # only, are not remembered so turning Active on brings back the + # mode the tile is showing instead of one it cannot display. + self._last_known_mode: HVACMode + if current_mode and self._hk_target_mode(current_mode) is not None: + self._last_known_mode = current_mode + else: + self._last_known_mode = self._hk_to_ha_target[default_target] + + self._write_lock = asyncio.Lock() + # A mode the entity accepted but does not report yet; push + # integrations can return from the service before their state + # callback arrives. + self._pending_mode: HVACMode | None = None + self._last_reported_mode = current_mode + + self.async_update_state(state) + + # A single service-level callback batches every characteristic write. + serv.setter_callback = self._set_chars + + def _set_chars(self, char_values: dict[str, Any]) -> None: + """Handle writes to multiple HeaterCooler characteristics at once.""" + _LOGGER.debug("HeaterCooler _set_chars: %s", char_values) + # Batches are resolved and applied under the accessory lock so a + # batch sees the outcome of the one before it and cannot overtake + # or interleave with it. + self.hass.async_create_task( + self._async_apply_batch(char_values), eager_start=True + ) + + @_locked_write + async def _async_apply_batch(self, char_values: dict[str, Any]) -> None: + """Resolve one characteristic batch and apply its writes in order. + + A failed write aborts the rest of the batch, since the tile was + already re-synced and later writes would target a mode the entity + refused to enter. + """ + service_calls: list[ClimateServiceCall] = [] + current_state = self.hass.states.get(self.entity_id) + active = char_values.get(CHAR_ACTIVE) + + # A mode written in the batch wins over the entity state, which + # still holds the pre-change mode. + requested_mode: HVACMode | None = None + if ( + target_mode := char_values.get(CHAR_TARGET_HEATER_COOLER_STATE) + ) is not None: + requested_mode = self._hk_to_ha_target.get(target_mode) + elif active == 1: + # Turning on activates the last known mode, so setpoints in + # the same batch resolve against it instead of the off state. + requested_mode = self._last_known_mode + + # Active/mode changes are handled first as they gate the others. + if self._handle_active_mode_changes( + active, target_mode, service_calls, current_state, requested_mode + ): + # A just accepted mode stays effective for the setpoints until + # the entity reports it, so a following batch does not resolve + # against the pre-switch state. + self._handle_temperature_changes( + char_values, + service_calls, + current_state, + requested_mode or self._pending_mode, + ) + # Fan and swing are queued last so they follow the mode switch. + self._queue_fan_swing_changes(char_values, service_calls) + + for call in service_calls: + reported_mode = self._last_reported_mode + known_mode = self._last_known_mode + if not await self.async_call_service_and_wait( + CLIMATE_DOMAIN, + call.service, + {ATTR_ENTITY_ID: self.entity_id, **call.data}, + ): + return + # A state callback during the blocking call is fresher than the + # queued values, so each one only applies when its counterpart + # was not updated while awaiting. The remembered mode mirrors + # the accepted target, so a rejected mode is not restored later. + if call.pending_mode and self._last_reported_mode == reported_mode: + self._pending_mode = call.pending_mode + if call.commit_mode and self._last_known_mode == known_mode: + self._last_known_mode = call.commit_mode + + @override + def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None: + """Serialize the write behind any batch still being applied.""" + self.hass.async_create_task( + self._async_apply_locked_write(service, params), eager_start=True + ) + + @_locked_write + async def _async_apply_locked_write( + self, service: str, params: dict[str, Any] + ) -> None: + """Await one write under the accessory lock.""" + await self.async_call_service_and_wait( + CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params} + ) + + def _queue_fan_swing_changes( + self, + char_values: dict[str, Any], + service_calls: list[ClimateServiceCall], + ) -> None: + """Queue fan speed and swing mode changes.""" + if ( + CHAR_ROTATION_SPEED in char_values + and (params := self._fan_speed_params(char_values[CHAR_ROTATION_SPEED])) + is not None + ): + service_calls.append(ClimateServiceCall(SERVICE_SET_FAN_MODE, params)) + if ( + CHAR_SWING_MODE in char_values + and (params := self._swing_mode_params(char_values[CHAR_SWING_MODE])) + is not None + ): + service_calls.append(ClimateServiceCall(SERVICE_SET_SWING_MODE, params)) + + def _handle_active_mode_changes( + self, + active: int | None, + target_mode: int | None, + service_calls: list[ClimateServiceCall], + current_state: State | None, + requested_mode: HVACMode | None, + ) -> bool: + """Handle active and mode changes. + + Returns False when an off write terminates the batch; a rejected + off leaves the entity running, so the rest still applies. + """ + if target_mode is not None and requested_mode is None: + # The write already changed the characteristic to a target the + # entity cannot enter, so put it back on the last mode. + if (restore := self._hk_target_mode(self._last_known_mode)) is not None: + self._reject_char_write(self.char_target_state, restore) + + if active == 0: + # climate.turn_off raises for entities without an OFF mode; set the + # OFF mode directly and only when it is supported, like the thermostat. + if self._supports_off: + # A target bundled with off is already on the tile rather + # than sent, so it is committed with the accepted off write + # and restored by the next Active on. + service_calls.append( + ClimateServiceCall( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + commit_mode=requested_mode, + pending_mode=HVACMode.OFF, + ) + ) + return False + _LOGGER.debug( + "%s: Ignoring off request; entity has no off mode", + self.entity_id, + ) + # The write already flipped the characteristic; flip it back so + # HomeKit keeps showing the unit as on, and let the rest of the + # batch apply since the entity keeps running. + self._reject_char_write(self.char_active, 1) + if requested_mode and ( + target_mode is not None + or current_state is None + or self._pending_mode == HVACMode.OFF + or current_state.state in CLIMATE_INACTIVE_STATES + ): + # An explicit target always goes out; Active on sends the last + # known mode only when the entity is not already running, where + # a pending off write means it is about to stop running. + service_calls.append( + ClimateServiceCall( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: requested_mode}, + commit_mode=requested_mode, + pending_mode=requested_mode, + ) + ) + return True + + def _handle_temperature_changes( + self, + char_values: dict[str, Any], + service_calls: list[ClimateServiceCall], + current_state: State | None, + requested_mode: HVACMode | None, + ) -> None: + """Handle temperature changes.""" + cooling_temp = char_values.get(CHAR_COOLING_THRESHOLD_TEMPERATURE) + heating_temp = char_values.get(CHAR_HEATING_THRESHOLD_TEMPERATURE) + + if cooling_temp is None and heating_temp is None: + return + + # Entities that support both single and range targets publish the + # range keys even when they are unset, so the effective mode decides + # between a range and a single setpoint write; entities that only + # take a range always get one. + attributes = current_state.attributes if current_state else {} + effective_mode: HVACMode | str | None = requested_mode or ( + current_state.state if current_state else None + ) + use_range = ( + self._has_cool_threshold + and self._has_heat_threshold + and ( + ATTR_TARGET_TEMP_HIGH in attributes + or ATTR_TARGET_TEMP_LOW in attributes + ) + and (effective_mode in RANGE_MODES or ATTR_TEMPERATURE not in attributes) + ) + + if use_range: + service_calls.append( + ClimateServiceCall( + SERVICE_SET_TEMPERATURE, + self._dual_setpoint_params( + self.char_cool, self.char_heat, cooling_temp, heating_temp + ), + ) + ) + else: + self._handle_single_temp_changes( + service_calls, cooling_temp, heating_temp, current_state, effective_mode + ) + + def _handle_single_temp_changes( + self, + service_calls: list[ClimateServiceCall], + cooling_temp: float | None, + heating_temp: float | None, + current_state: State | None, + effective_mode: HVACMode | str | None, + ) -> None: + """Handle temperature changes for single-temperature entities.""" + if not current_state: + return + + # For a single setpoint the effective mode decides which threshold is + # the setpoint; Cool uses the cooling side and Heat the heating side, + # so a write to the other side is ignored. Range and other modes fall + # back to whichever threshold moved, and Auto picks the one furthest + # from the current setpoint. + selected_temp = None + if effective_mode == HVACMode.COOL: + selected_temp = cooling_temp + elif effective_mode == HVACMode.HEAT: + selected_temp = heating_temp + elif ( + effective_mode in RANGE_MODES + and cooling_temp is not None + and heating_temp is not None + ): + # Pick whichever threshold moved further from the entity's existing + # target setpoint. The thresholds are in HomeKit units, so convert + # the target setpoint before comparing. + target_temp = current_state.attributes.get(ATTR_TEMPERATURE) + if target_temp is None: + selected_temp = heating_temp + else: + target_temp_hk = self._temperature_to_homekit(target_temp) + if abs(cooling_temp - target_temp_hk) > abs( + heating_temp - target_temp_hk + ): + selected_temp = cooling_temp + else: + selected_temp = heating_temp + elif cooling_temp is not None: + selected_temp = cooling_temp + elif heating_temp is not None: + selected_temp = heating_temp + + if selected_temp is not None: + ha_temp = self._temperature_to_states(selected_temp) + service_calls.append( + ClimateServiceCall(SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: ha_temp}) + ) + + def _hk_target_mode(self, mode: HVACMode) -> int | None: + """Map HA hvac_mode to a HomeKit target heater-cooler state.""" + # HomeKit's HeaterCooler target only has Auto/Heat/Cool, so modes like + # dry and fan_only have no representation; they are intentionally + # collapsed to the Auto target (see the fallback in __init__) and cannot + # be selected or reflected individually from the Home app. + hk_value = HC_HASS_TO_HOMEKIT_TARGET.get(mode) + if hk_value is not None and hk_value in self._hk_to_ha_target: + return hk_value + return self._ha_to_hk_target.get(mode) + + @callback + @override + def async_update_state(self, new_state: State) -> None: + """Update state without rechecking the device features.""" + attributes = new_state.attributes + current_mode = try_parse_enum(HVACMode, new_state.state) + if current_mode is not None: + if current_mode != self._last_reported_mode: + # The entity moved to a new mode, so its state is + # authoritative again; re-reports of the pre-switch mode, + # like attribute updates mid transition, keep the bridge. + self._pending_mode = None + self._last_reported_mode = current_mode + # While a write is pending, the accepted mode stays the displayed + # and restore target so a stale re-report cannot flip the tile back. + display_mode = self._pending_mode or current_mode + if display_mode and (tgt := self._hk_target_mode(display_mode)) is not None: + self._last_known_mode = display_mode + self.char_target_state.set_value(tgt) + + if new_state.state in CLIMATE_INACTIVE_STATES: + # An off or unavailable entity is inactive, not idle. + self.char_active.set_value(0) + self.char_current_state.set_value(HC_INACTIVE) + else: + self.char_active.set_value(1) + action = attributes.get(ATTR_HVAC_ACTION) or self._derive_action( + new_state, current_mode + ) + self.char_current_state.set_value( + HC_HASS_TO_HOMEKIT_ACTION.get(action, HC_INACTIVE) + ) + + self._update_current_temperature_char(new_state) + self._update_temperature_thresholds(new_state) + if self._has_humidity and isinstance( + (humidity := attributes.get(ATTR_CURRENT_HUMIDITY)), (int, float) + ): + self.char_current_humidity.set_value(humidity) + if self.fan_chars: + self._async_update_fan_service(new_state) + else: + # The base char updaters no-op when the entity exposes no fan/swing. + self._update_fan_speed_char(attributes) + self._update_swing_char(attributes) + + def _update_temperature_thresholds(self, state: State) -> None: + """Update HomeKit temperature thresholds based on HA state.""" + if not self._has_cool_threshold and not self._has_heat_threshold: + return + attributes = state.attributes + # Dual capable entities publish the range keys even in single + # setpoint modes, so only values decide what is displayed. + supports_dual_temp = ( + attributes.get(ATTR_TARGET_TEMP_HIGH) is not None + or attributes.get(ATTR_TARGET_TEMP_LOW) is not None + ) + + if supports_dual_temp: + if self._has_cool_threshold: + self._update_temperature_char( + self.char_cool, state, ATTR_TARGET_TEMP_HIGH + ) + if self._has_heat_threshold: + self._update_temperature_char( + self.char_heat, state, ATTR_TARGET_TEMP_LOW + ) + elif ( + target_temp := temperature_attribute_to_homekit( + state, ATTR_TEMPERATURE, self._unit + ) + ) is not None: + if self._has_cool_threshold: + self.char_cool.set_value(target_temp) + if self._has_heat_threshold: + self.char_heat.set_value(target_temp) + + def _derive_action(self, state: State, mode: HVACMode | None) -> HVACAction: + """Infer heating / cooling when integration omits hvac_action.""" + attributes = state.attributes + cur = attributes.get(ATTR_CURRENT_TEMPERATURE) + if cur is None or mode is None: + return HVACAction.IDLE + + # Resolve the cool-above and heat-below setpoints for the active mode. + # Range modes have independent thresholds; single-target modes only + # drive one side. Any other mode (e.g. dry, fan_only) stays idle. + if mode in RANGE_MODES: + cool_above = attributes.get(ATTR_TARGET_TEMP_HIGH) + heat_below = attributes.get(ATTR_TARGET_TEMP_LOW) + if cool_above is None and heat_below is None: + # Some integrations run auto from a single setpoint. + cool_above = heat_below = attributes.get(ATTR_TEMPERATURE) + elif mode == HVACMode.COOL: + cool_above = attributes.get(ATTR_TEMPERATURE) + heat_below = None + elif mode == HVACMode.HEAT: + cool_above = None + heat_below = attributes.get(ATTR_TEMPERATURE) + else: + return HVACAction.IDLE + + # Compare in Celsius so the hysteresis band is unit independent. + cur_c = self._temperature_to_homekit(cur) + if ( + cool_above is not None + and cur_c > self._temperature_to_homekit(cool_above) + ACTION_HYSTERESIS + ): + return HVACAction.COOLING + if ( + heat_below is not None + and cur_c < self._temperature_to_homekit(heat_below) - ACTION_HYSTERESIS + ): + return HVACAction.HEATING + return HVACAction.IDLE diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 1b570c8e4279..72352dde87b0 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -189,7 +189,6 @@ class Thermostat(HomeKitClimateAccessory): self.chars.append(CHAR_TARGET_HUMIDITY) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) - self.set_primary_service(serv_thermostat) # Current mode characteristics self.char_current_heat_cool = serv_thermostat.configure_char( @@ -276,6 +275,10 @@ class Thermostat(HomeKitClimateAccessory): self.fan_chars.append(CHAR_CURRENT_FAN_STATE) self._configure_fan_service(serv_thermostat) + # Every service exists now, so they all get an explicit primary + # flag; without one the Home app can pick its own tile service. + self.set_primary_service(serv_thermostat) + self.async_update_state(state) serv_thermostat.setter_callback = self._set_chars diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 97269181d58a..bc1b65a365c9 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -106,10 +106,12 @@ from .const import ( TYPE_AIR_PURIFIER, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, @@ -225,6 +227,21 @@ COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +# No default so an unset type keeps the automatic Thermostat/HeaterCooler routing. +CLIMATE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE): vol.All( + cv.string, + vol.In( + ( + TYPE_HEATER_COOLER, + TYPE_THERMOSTAT, + ) + ), + ), + } +) + CODE_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)} ) @@ -360,6 +377,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "humidifier": config = HUMIDIFIER_SCHEMA(config) + elif domain == "climate": + config = CLIMATE_SCHEMA(config) + elif domain == "cover": config = COVER_SCHEMA(config) diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 8a3999f9514b..583853177644 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -740,7 +740,7 @@ async def test_call_service( ], ) async def test_call_service_resyncs_on_failure( - hass: HomeAssistant, hk_driver, raise_exception: Exception + hass: HomeAssistant, hk_driver: HomeDriver, raise_exception: Exception ) -> None: """When the dispatched service raises, re-push the entity's current state. @@ -768,6 +768,51 @@ async def test_call_service_resyncs_on_failure( assert pushed_state.state == STATE_OFF +async def test_call_service_resync_failure_is_logged( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a failing resync is logged instead of hitting the task handler.""" + entity_id = "homekit.accessory" + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + async_mock_service( + hass, "cover", "open_cover", raise_exception=HomeAssistantError("nope") + ) + + with patch.object( + acc, "async_update_state", side_effect=RuntimeError("resync boom") + ): + acc.async_call_service( + "cover", "open_cover", {ATTR_ENTITY_ID: entity_id}, "value" + ) + await hass.async_block_till_done() + + assert "re-syncing HomeKit state failed" in caplog.text + + +async def test_call_service_resync_skip_is_logged( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a resync skipped for a missing entity state is diagnosable.""" + entity_id = "homekit.accessory" + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + async_mock_service( + hass, "cover", "open_cover", raise_exception=HomeAssistantError("nope") + ) + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + acc.async_call_service("cover", "open_cover", {ATTR_ENTITY_ID: entity_id}, "value") + await hass.async_block_till_done() + + assert "cannot re-sync HomeKit state" in caplog.text + + def test_home_bridge(hk_driver) -> None: """Test HomeBridge class.""" bridge = HomeBridge("hass", hk_driver, BRIDGE_NAME) diff --git a/tests/components/homekit/test_accessory_type.py b/tests/components/homekit/test_accessory_type.py new file mode 100644 index 000000000000..bb019a5137ef --- /dev/null +++ b/tests/components/homekit/test_accessory_type.py @@ -0,0 +1,399 @@ +"""Test the HomeKit accessory type routing.""" + +import asyncio +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODES, + ATTR_HVAC_MODES, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homekit import HomeKit +from homeassistant.components.homekit.const import ( + DEFAULT_PORT, + DOMAIN, + HOMEKIT_MODE_ACCESSORY, + HOMEKIT_MODE_BRIDGE, + PERSIST_LOCK_DATA, +) +from homeassistant.components.homekit.util import get_aid_storage_filename_for_entry_id +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entityfilter import ( + CONF_EXCLUDE_DOMAINS, + CONF_EXCLUDE_ENTITIES, + CONF_EXCLUDE_ENTITY_GLOBS, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, + CONF_INCLUDE_ENTITY_GLOBS, + convert_filter, +) + +from .util import PATH_HOMEKIT + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def patch_source_ip() -> Generator[None]: + """Patch homeassistant and pyhap functions for getting local address.""" + with patch("pyhap.util.get_local_address", return_value="10.10.10.10"): + yield + + +ENTITY_ID = "climate.demo" +CAPABLE_ATTRS = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], +} + + +HUMIDITY_ATTRS = { + **CAPABLE_ATTRS, + ATTR_SUPPORTED_FEATURES: ( + CAPABLE_ATTRS[ATTR_SUPPORTED_FEATURES] | ClimateEntityFeature.TARGET_HUMIDITY + ), +} + + +async def _async_stop_bridge(homekit: HomeKit) -> None: + """Stop the bridge and flush the delayed aid storage save. + + The flush lets a following start read the stored routing choices. + """ + with patch("pyhap.accessory_driver.AccessoryDriver.async_stop"): + await homekit.async_stop() + assert homekit.aid_storage is not None + await homekit.aid_storage.async_save() + + +async def _async_start_bridge( + hass: HomeAssistant, + entry: MockConfigEntry, + entity_config: dict[str, Any] | None = None, + homekit_mode: str = HOMEKIT_MODE_BRIDGE, + existing_pairing: bool = False, +) -> HomeKit: + """Start a HomeKit instance exposing the demo climate entity.""" + hass.data.setdefault(PERSIST_LOCK_DATA, asyncio.Lock()) + entity_filter = convert_filter( + { + CONF_INCLUDE_DOMAINS: [], + CONF_INCLUDE_ENTITIES: [ENTITY_ID], + CONF_EXCLUDE_DOMAINS: [], + CONF_EXCLUDE_ENTITIES: [], + CONF_INCLUDE_ENTITY_GLOBS: [], + CONF_EXCLUDE_ENTITY_GLOBS: [], + } + ) + homekit = HomeKit( + hass=hass, + name="mock_name", + port=DEFAULT_PORT, + ip_address=None, + entity_filter=entity_filter, + exclude_accessory_mode=False, + entity_config=entity_config or {}, + homekit_mode=homekit_mode, + advertise_ips=None, + entry_id=entry.entry_id, + entry_title=entry.title, + ) + original_setup = HomeKit.setup + + def _setup(homekit: HomeKit, async_zeroconf_instance: Any, uuid: str) -> bool: + """Run the real driver setup, faking persisted pairing state if asked.""" + loaded = original_setup(homekit, async_zeroconf_instance, uuid) + return loaded or existing_pairing + + with ( + patch(f"{PATH_HOMEKIT}.async_show_setup_message"), + patch("pyhap.accessory_driver.AccessoryDriver.async_start"), + patch(f"{PATH_HOMEKIT}.HomeKit.setup", _setup), + ): + await homekit.async_start() + await hass.async_block_till_done() + return homekit + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_existing_entity_stays_thermostat( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test a previously bridged entity keeps the Thermostat.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + accessories = list(homekit.bridge.accessories.values()) + assert len(accessories) == 1 + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_new_entity_routes_to_heater_cooler( + hass: HomeAssistant, +) -> None: + """Test a never bridged entity gets the HeaterCooler.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + accessories = list(homekit.bridge.accessories.values()) + assert len(accessories) == 1 + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_reset_does_not_allocate_for_unbridged_entities( + hass: HomeAssistant, +) -> None: + """Test resetting an entity another bridge owns does not allocate.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + # The reset service fans out to every instance, so an allocation here + # would wrongly mark the entity as previously bridged + await homekit.async_reset_accessories(["climate.not_bridged_here"]) + assert homekit.aid_storage is not None + assert ( + homekit.aid_storage.get_allocated_aid_for_entity_id("climate.not_bridged_here") + is None + ) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_failed_accessory_creation_is_not_recorded( + hass: HomeAssistant, +) -> None: + """Test a failed accessory creation does not persist the auto routing.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=ValueError): + homekit = await _async_start_bridge(hass, entry) + + assert homekit.aid_storage is not None + assert homekit.aid_storage.get_accessory_type(ENTITY_ID) is None + # The aid allocation is rolled back so the entity is still new + assert not homekit.aid_storage.allocations + await _async_stop_bridge(homekit) + + # The next start treats the entity as never bridged and retries + # the automatic choice + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_heater_cooler_choice_survives_restart( + hass: HomeAssistant, +) -> None: + """Test the automatic HeaterCooler choice persists across restarts.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + # The entity now has an aid allocation, so only the stored choice + # keeps it on the HeaterCooler after a restart. + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_gained_humidity_setpoint_drops_stored_choice( + hass: HomeAssistant, +) -> None: + """Test a stored HeaterCooler choice is dropped for a humidity setpoint.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + # The entity gains a humidity setpoint, which the HeaterCooler cannot + # control, so the stored routing is dropped + hass.states.async_set( + ENTITY_ID, + HVACMode.COOL, + HUMIDITY_ATTRS, + ) + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + assert homekit.aid_storage is not None + assert homekit.aid_storage.get_accessory_type(ENTITY_ID) is None + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_automatic_keeps_explicit_choice( + hass: HomeAssistant, +) -> None: + """Test an explicit type updates the stored routing for automatic.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + # A new entity picks the HeaterCooler and the choice is stored + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + # An explicit Thermostat overrides and updates the stored routing + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "thermostat"}} + ) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + # Back on automatic the entity keeps the Thermostat instead of + # flipping back to the HeaterCooler + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_accessory_mode_existing_pairing_stays_thermostat( + hass: HomeAssistant, +) -> None: + """Test an existing accessory mode pairing keeps the Thermostat.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY, existing_pairing=True + ) + + assert type(homekit.driver.accessory).__name__ == "Thermostat" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_accessory_mode_new_pairing_routes_heater_cooler( + hass: HomeAssistant, +) -> None: + """Test a brand new accessory mode pairing picks the HeaterCooler.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY + ) + + assert type(homekit.driver.accessory).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_explicit_heater_cooler_wins_over_humidity_safeguard( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test an explicit heater_cooler type wins for a humidity entity.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set( + ENTITY_ID, + HVACMode.COOL, + HUMIDITY_ATTRS, + ) + + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "heater_cooler"}} + ) + + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "HeaterCooler" + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_explicit_type_wins_for_existing_entity( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test an entity with a configured type uses it.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "thermostat"}} + ) + + accessories = list(homekit.bridge.accessories.values()) + assert type(accessories[0]).__name__ == "Thermostat" + await _async_stop_bridge(homekit) diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 6dbac422f079..1a12cfb1636d 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -647,3 +647,158 @@ async def test_handle_unique_id_change( # Verify that the old unique id is removed from the allocations # and that the new unique id assumes the old aid assert aid_storage.allocations == {"demo.light.new_unique": 4202023227} + + +async def test_entity_is_allocated( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test detecting whether an entity already has an allocated aid.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + light_ent = entity_registry.async_get_or_create( + "light", "device", "unique_id", device_id=device_entry.id + ) + hass.states.async_set(light_ent.entity_id, "on") + hass.states.async_set("remote.has_no_unique_id", "on") + + with patch( + "homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save" + ): + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + + # Nothing allocated yet + assert not aid_storage.entity_is_allocated(light_ent.entity_id) + assert not aid_storage.entity_is_allocated("remote.has_no_unique_id") + + # Allocation is keyed by the system unique id for registered entities + aid_storage.get_or_allocate_aid_for_entity_id(light_ent.entity_id) + assert aid_storage.entity_is_allocated(light_ent.entity_id) + + # Unregistered entities are keyed by entity id + aid_storage.get_or_allocate_aid_for_entity_id("remote.has_no_unique_id") + assert aid_storage.entity_is_allocated("remote.has_no_unique_id") + + # A changed unique id is still recognized through previous_unique_id + entity_registry.async_update_entity( + light_ent.entity_id, new_unique_id="new_unique_id" + ) + assert aid_storage.entity_is_allocated(light_ent.entity_id) + + +async def test_accessory_type_round_trip(hass: HomeAssistant) -> None: + """Test the stored accessory type persists through storage.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + assert aid_storage.get_accessory_type("climate.demo") is None + + # Setting the current value again is a no-op + aid_storage.async_set_accessory_type("climate.demo", None) + assert aid_storage.get_accessory_type("climate.demo") is None + + aid_storage.async_set_accessory_type("climate.demo", "heater_cooler") + aid_storage.async_set_accessory_type("climate.demo", "heater_cooler") + await aid_storage.async_save() + + fresh_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await fresh_storage.async_initialize() + assert fresh_storage.get_accessory_type("climate.demo") == "heater_cooler" + + fresh_storage.async_set_accessory_type("climate.demo", None) + await fresh_storage.async_save() + + final_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await final_storage.async_initialize() + assert final_storage.get_accessory_type("climate.demo") is None + + +async def test_accessory_type_survives_repeated_unique_id_changes( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test reads heal the stored key so a second migration cannot orphan it.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + climate_ent = entity_registry.async_get_or_create( + "climate", "device", "u1", device_id=device_entry.id + ) + + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + aid_storage.async_set_accessory_type(climate_ent.entity_id, "heater_cooler") + + # Only the latest previous unique id stays resolvable, so the read + # moves the entry forward after each migration + entity_registry.async_update_entity(climate_ent.entity_id, new_unique_id="u2") + await hass.async_block_till_done() + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + assert aid_storage.accessory_types == {"device.climate.u2": "heater_cooler"} + + entity_registry.async_update_entity(climate_ent.entity_id, new_unique_id="u3") + await hass.async_block_till_done() + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + assert aid_storage.accessory_types == {"device.climate.u3": "heater_cooler"} + + +async def test_accessory_type_survives_entity_renames( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the stored accessory type follows the entity through renames.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + climate_ent = entity_registry.async_get_or_create( + "climate", "device", "unique_id", device_id=device_entry.id + ) + + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + + aid_storage.async_set_accessory_type(climate_ent.entity_id, "heater_cooler") + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + # Registered entities are stored by the system unique id + assert aid_storage.accessory_types == {"device.climate.unique_id": "heater_cooler"} + + # An entity id rename keeps the choice through the stable identity + entity_registry.async_update_entity( + climate_ent.entity_id, new_entity_id="climate.renamed" + ) + await hass.async_block_till_done() + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # A unique id change is still recognized through previous_unique_id + entity_registry.async_update_entity( + "climate.renamed", new_unique_id="new_unique_id" + ) + await hass.async_block_till_done() + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # Allocating migrates the stored choice to the new unique id + aid_storage.get_or_allocate_aid_for_entity_id("climate.renamed") + assert aid_storage.accessory_types == { + "device.climate.new_unique_id": "heater_cooler" + } + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # Clearing the choice removes the stored identity + aid_storage.async_set_accessory_type("climate.renamed", None) + assert aid_storage.get_accessory_type("climate.renamed") is None + assert not aid_storage.accessory_types diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index a1d4b0dd0517..613f6876f63b 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -337,6 +337,12 @@ async def test_options_flow_exclude_mode(hass: HomeAssistant) -> None: user_input={"entities": ["climate.old"]}, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): @@ -391,7 +397,7 @@ async def test_options_flow_devices( demo_config_entry.add_to_hass(hass) with patch("homeassistant.components.homekit.HomeKit") as mock_homekit: - mock_homekit.return_value = homekit = Mock() + mock_homekit.return_value = homekit = Mock(bridge=None, driver=None) type(homekit).async_start = AsyncMock() assert await async_setup_component(hass, DOMAIN, {"homekit": {}}) assert await async_setup_component(hass, "homeassistant", {}) @@ -428,6 +434,12 @@ async def test_options_flow_devices( }, ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" # The stale "notexist" device must be stripped from the form @@ -467,6 +479,12 @@ async def test_options_flow_devices( result["flow_id"], user_input={"entities": ["climate.old"]}, ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) assert result2["step_id"] == "bridged_device_triggers" assert result2["data_schema"]({})["devices"] == [device_id] @@ -519,6 +537,12 @@ async def test_options_flow_include_mode_with_non_existant_entity( }, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" result3 = await hass.config_entries.options.async_configure( @@ -639,6 +663,12 @@ async def test_options_flow_include_mode_basic(hass: HomeAssistant) -> None: user_input={"entities": ["climate.new"]}, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" result3 = await hass.config_entries.options.async_configure( @@ -818,6 +848,13 @@ async def test_options_flow_include_mode_with_cameras(hass: HomeAssistant) -> No user_input={"camera_copy": ["camera.native_h264"]}, ) assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "climate" + + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "bridged_device_triggers" result4 = await hass.config_entries.options.async_configure( @@ -962,6 +999,13 @@ async def test_options_flow_with_camera_audio(hass: HomeAssistant) -> None: user_input={"camera_audio": ["camera.audio"]}, ) assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "climate" + + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "bridged_device_triggers" result4 = await hass.config_entries.options.async_configure( @@ -1611,3 +1655,200 @@ async def test_options_flow_include_mode_allows_hidden_entities( } await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_options_flow_climate_accessory_type_round_trip( + hass: HomeAssistant, +) -> None: + """Test setting and clearing the climate accessory type.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + await hass.async_block_till_done() + + async def _configure(choice: str) -> None: + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["climate"], + "include_exclude_mode": "include", + }, + ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": ["climate.new"]}, + ) + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"new (climate.new)": choice}, + ) + assert result2["step_id"] == "bridged_device_triggers" + with patch( + "homeassistant.components.homekit.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + await _configure("heater_cooler") + assert config_entry.options["entity_config"]["climate.new"]["type"] == ( + "heater_cooler" + ) + + await _configure("thermostat") + assert config_entry.options["entity_config"]["climate.new"]["type"] == "thermostat" + + await _configure("automatic") + assert "entity_config" not in config_entry.options + + +async def test_options_flow_cameras_step_with_whole_domain_included( + hass: HomeAssistant, +) -> None: + """Test the cameras step is offered for a whole camera domain include.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("camera.native_h264", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["fan", "camera"], + "include_exclude_mode": "include", + }, + ) + assert result["step_id"] == "include" + + # No camera is selected explicitly, so the whole domain is included + # and the camera options are still offered + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": []}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "cameras" + await hass.config_entries.async_unload(config_entry.entry_id) + + +@pytest.mark.parametrize("homekit_mode", ["bridge", "accessory"]) +async def test_options_flow_climate_step_shows_current_accessory( + hass: HomeAssistant, homekit_mode: str +) -> None: + """Test the climate labels show the accessory the entity uses now.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + await hass.async_block_till_done() + + # A loaded entry exposes the bridged accessories through its runtime + # data; accessory mode reads the single accessory from the driver + thermostat = type("Thermostat", (), {"entity_id": "climate.new"})() + if homekit_mode == "bridge": + homekit = Mock(bridge=Mock(accessories={2: thermostat})) + else: + homekit = Mock(bridge=None, driver=Mock(accessory=thermostat)) + config_entry.runtime_data = Mock(homekit=homekit) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["climate"], + "include_exclude_mode": "include", + }, + ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": ["climate.new"]}, + ) + assert result2["step_id"] == "climate" + assert [str(key) for key in result2["data_schema"].schema] == [ + "new (climate.new) [Thermostat]" + ] + + # The annotated label still round trips to the entity id + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"new (climate.new) [Thermostat]": "heater_cooler"}, + ) + assert result2["step_id"] == "bridged_device_triggers" + with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options["entity_config"]["climate.new"]["type"] == ( + "heater_cooler" + ) + + +async def test_options_flow_climate_step_with_whole_domain_included( + hass: HomeAssistant, +) -> None: + """Test the climate step lists all climate entities for a domain include.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["fan", "climate"], + "include_exclude_mode": "include", + }, + ) + assert result["step_id"] == "include" + + # No climate entity is selected explicitly, so the whole domain is + # included and every climate entity is offered in the climate step. + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": []}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + assert [str(key) for key in result2["data_schema"].schema] == [ + "new (climate.new)", + "old (climate.old)", + ] + + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ + "new (climate.new)": "heater_cooler", + "old (climate.old)": "automatic", + }, + ) + assert result2["step_id"] == "bridged_device_triggers" + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + "devices": [], + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["climate", "fan"], + "include_entities": [], + }, + "entity_config": {"climate.new": {"type": "heater_cooler"}}, + } + await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 91b192d283f2..a6247c9a293c 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -4,20 +4,26 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.climate import ATTR_CURRENT_HUMIDITY, ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.homekit import TYPE_AIR_PURIFIER -from homeassistant.components.homekit.accessories import TYPES, get_accessory +from homeassistant.components.homekit.accessories import ( + TYPES, + climate_supports_heater_cooler, + get_accessory, +) from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from homeassistant.components.homekit.type_sensors import ( @@ -455,6 +461,239 @@ def test_type_camera(type_name, entity_id, state, attrs) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs"), + [ + # Basic climate without fan/swing support -> Thermostat + ("Thermostat", "climate.basic", "heat", {}), + # Climate with only FAN_MODE feature but no fan_modes -> Thermostat + ( + "Thermostat", + "climate.fan_feature_only", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE}, + ), + # Climate with only SWING_MODE feature but no swing_modes -> Thermostat + ( + "Thermostat", + "climate.swing_feature_only", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE}, + ), + # Climate with FAN_MODE feature and fan_modes list -> HeaterCooler + ( + "HeaterCooler", + "climate.with_fan", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "medium", "high"], + }, + ), + # Timing fan modes (auto/on/off/circulate) are not predefined speeds, so + # an entity exposing only those has zero speeds -> Thermostat + ( + "Thermostat", + "climate.timing_fan_modes_only", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate"], + }, + ), + # Those timing modes plus a single predefined speed still count as one + # speed, which cannot drive the slider -> Thermostat + ( + "Thermostat", + "climate.single_fan_speed", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate", "high"], + }, + ), + # Timing modes are ignored, but two real speeds among them qualify + ( + "HeaterCooler", + "climate.two_speeds_with_timing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate", "low", "high"], + }, + ), + # A single predefined fan speed plus a swing mode -> HeaterCooler + ( + "HeaterCooler", + "climate.single_fan_speed_with_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["auto", "high"], + "swing_modes": ["on", "off"], + }, + ), + # Climate with SWING_MODE feature and swing_modes list -> HeaterCooler + ( + "HeaterCooler", + "climate.with_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["on", "off"], + }, + ), + # Climate with both FAN_MODE and SWING_MODE features and modes -> HeaterCooler + ( + "HeaterCooler", + "climate.with_fan_and_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["low", "high"], + "swing_modes": ["on", "off"], + }, + ), + # Climate with FAN_MODE feature and empty fan_modes list -> Thermostat + ( + "Thermostat", + "climate.empty_fan_modes", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, "fan_modes": []}, + ), + # Climate with SWING_MODE feature and empty swing_modes list -> Thermostat + ( + "Thermostat", + "climate.empty_swing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": [], + }, + ), + # Climate with only custom (non-predefined) fan modes -> Thermostat + ( + "Thermostat", + "climate.custom_fan_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["quiet", "turbo"], + }, + ), + # Climate with only custom (non-predefined) swing modes -> Thermostat + ( + "Thermostat", + "climate.custom_swing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["off", "custom"], + }, + ), + # Swing without an advertised off mode -> Thermostat (off writes + # would be rejected by the entity) + ( + "Thermostat", + "climate.swing_without_off", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["vertical"], + }, + ), + # Climate with other features but no fan/swing -> Thermostat + ( + "Thermostat", + "climate.other_features", + "heat", + { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + }, + ), + # Fan speeds with a humidity setpoint -> Thermostat (controls humidity) + ( + "Thermostat", + "climate.fan_and_target_humidity", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_HUMIDITY, + "fan_modes": ["low", "high"], + }, + ), + # Fan speeds with display-only humidity -> HeaterCooler (kept via sensor) + ( + "HeaterCooler", + "climate.fan_and_current_humidity", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "high"], + ATTR_CURRENT_HUMIDITY: 45, + }, + ), + ], +) +def test_climate_supports_heater_cooler( + type_name: str, entity_id: str, state: str, attrs: dict[str, object] +) -> None: + """Test the capability predicate behind automatic HeaterCooler routing.""" + entity_state = State(entity_id, state, attrs) + assert climate_supports_heater_cooler(entity_state) is (type_name == "HeaterCooler") + + +def test_climate_without_configured_type_is_thermostat() -> None: + """Test a climate entity without a configured type gets the Thermostat.""" + attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "high"], + } + mock_type = Mock() + with patch.dict(TYPES, {"Thermostat": mock_type}): + entity_state = State("climate.test", "heat", attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called + + +@pytest.mark.parametrize( + ("config_type", "attrs", "type_name"), + [ + # Would auto-route to Thermostat, but the config forces HeaterCooler + ( + TYPE_HEATER_COOLER, + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE}, + "HeaterCooler", + ), + # Would auto-route to HeaterCooler, but the config forces Thermostat + ( + TYPE_THERMOSTAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["low", "high"], + "swing_modes": ["on", "off"], + }, + "Thermostat", + ), + ], +) +def test_climate_accessory_type_override( + config_type: str, attrs: dict[str, object], type_name: str +) -> None: + """Test a configured type overrides the capability based routing.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State("climate.test", "heat", attrs) + get_accessory(None, None, entity_state, 2, {CONF_TYPE: config_type}) + assert mock_type.called + + @pytest.mark.parametrize( ("expected_type", "entity_id", "attrs"), [ diff --git a/tests/components/homekit/test_type_heater_coolers.py b/tests/components/homekit/test_type_heater_coolers.py new file mode 100644 index 000000000000..a36feaf42b5b --- /dev/null +++ b/tests/components/homekit/test_type_heater_coolers.py @@ -0,0 +1,3113 @@ +"""Test different accessory types: HeaterCooler.""" + +import asyncio +from typing import Any +from unittest.mock import patch + +from pyhap.const import ( + CATEGORY_AIR_CONDITIONER, + CATEGORY_HEATER, + HAP_REPR_AID, + HAP_REPR_CHARS, + HAP_REPR_IID, + HAP_REPR_VALUE, +) +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.climate_base import ( + FAN_STATE_ACTIVE, + FAN_STATE_IDLE, +) +from homeassistant.components.homekit.const import ( + CHAR_ACTIVE, + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_FAN_STATE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_NAME, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_HEATER_COOLER, + SERV_HUMIDITY_SENSOR, +) +from homeassistant.components.homekit.type_heater_coolers import ( + HC_COOLING, + HC_HEATING, + HC_IDLE, + HC_INACTIVE, + HC_TARGET_AUTO, + HC_TARGET_COOL, + HC_TARGET_HEAT, + HeaterCooler, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import async_mock_service + + +def _write_chars( + hk_driver: HomeDriver, acc: HeaterCooler, char_values: dict[str, float] +) -> None: + """Write characteristic values through the HAP client path.""" + serv = acc.get_service(SERV_HEATER_COOLER) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: serv.get_characteristic(name).to_HAP()[HAP_REPR_IID], + HAP_REPR_VALUE: value, + } + for name, value in char_values.items() + ] + }, + "mock_addr", + ) + + +async def test_heatercooler_basic(hass: HomeAssistant, hk_driver: HomeDriver) -> None: + """Test basic HeaterCooler functionality.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_MIN_TEMP: 10.0, + ATTR_MAX_TEMP: 30.0, + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.aid == 1 + assert acc.category == CATEGORY_AIR_CONDITIONER + + # Check initial state (OFF) + assert acc.char_active.value == 0 + assert acc.char_current_state.value == HC_INACTIVE # OFF reports Inactive + assert acc.char_target_state.value == HC_TARGET_AUTO + assert acc.char_current_temp.value == 18.0 + assert acc.char_cool.value == 20.0 + assert acc.char_heat.value == 20.0 + + # Check temperature properties + assert acc.char_cool.properties[PROP_MIN_VALUE] == 10.0 + assert acc.char_cool.properties[PROP_MAX_VALUE] == 30.0 + assert acc.char_heat.properties[PROP_MIN_VALUE] == 10.0 + assert acc.char_heat.properties[PROP_MAX_VALUE] == 30.0 + + # The mode and range attributes must trigger an accessory reload so the + # characteristic set stays in sync with the device. + assert set(acc._reload_on_change_attrs) >= { + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_HVAC_MODES, + } + + +async def test_heatercooler_with_fan_and_swing( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with fan and swing mode support.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_FAN_MODE: FAN_LOW, + ATTR_SWING_MODE: "off", + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Check that fan and swing characteristics are present + assert hasattr(acc, "char_speed") + assert hasattr(acc, "char_swing") + assert acc.char_speed.value == 25 # FAN_LOW maps to 25% (index 0 of 4 speeds) + assert acc.char_swing.value == 0 # off + + +@pytest.mark.parametrize( + ("hvac_modes", "expected_auto_mode"), + [ + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + HVACMode.AUTO, + id="auto_only", + ), + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL, HVACMode.OFF], + HVACMode.HEAT_COOL, + id="heat_cool_only", + ), + # HEAT_COOL keeps its thresholds adjustable, AUTO may follow a + # schedule, so HEAT_COOL backs the HomeKit Auto target + pytest.param( + [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.OFF, + ], + HVACMode.HEAT_COOL, + id="heat_cool_preferred_over_auto", + ), + ], +) +async def test_heatercooler_auto_target_backing_mode( + hass: HomeAssistant, + hk_driver: HomeDriver, + hvac_modes: list[HVACMode], + expected_auto_mode: HVACMode, +) -> None: + """Test which range mode backs the HomeKit Auto target.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: hvac_modes, + } + + hass.states.async_set(entity_id, hvac_modes[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc._hk_to_ha_target[HC_TARGET_AUTO] == expected_auto_mode + + +async def test_heatercooler_off_with_bundled_target_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a target bundled with off is remembered for the next power on.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Turning off with a new target only sends the off write + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + {CHAR_ACTIVE: 0, CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}, + ) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.OFF + + # Power on activates the mode the tile displays + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_off_with_unsupported_bundled_target( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target bundled with off is put back on the tile.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + {CHAR_ACTIVE: 0, CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}, + ) + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_rejected_mode_is_not_remembered( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected mode write does not become the restore mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + # The entity rejects the mode write while off + async_mock_service( + hass, + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + raise_exception=HomeAssistantError("mode rejected"), + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + assert acc._last_known_mode == HVACMode.HEAT + + # Turning Active on retries the last accepted mode, not the rejected one + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_modes_heat_cool_only_no_auto( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test that Auto is not offered for entities without a range mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Auto has no backing mode, so it must not be mapped or offered to HomeKit + assert HC_TARGET_AUTO not in acc._hk_to_ha_target + assert ( + HC_TARGET_AUTO not in acc.char_target_state.properties["ValidValues"].values() + ) + assert acc.char_target_state.value == HC_TARGET_HEAT + + +async def test_heatercooler_cooling_only_no_heat_target( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cooling-only entity does not expose the Heat target.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Heat has no backing mode, so HomeKit must not offer it + assert HC_TARGET_HEAT not in acc._hk_to_ha_target + valid_values = acc.char_target_state.properties["ValidValues"].values() + assert HC_TARGET_HEAT not in valid_values + assert HC_TARGET_AUTO not in valid_values + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_temperature_step( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler relies on the HomeKit default temperature step.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # No explicit min step so HomeKit keeps its 0.1 default for unit precision + assert acc.char_cool.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heat.properties[PROP_MIN_STEP] == 0.1 + + +async def test_heatercooler_fahrenheit( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with Fahrenheit units.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_MIN_TEMP: 45.0, # Fahrenheit + ATTR_MAX_TEMP: 95.0, # Fahrenheit + ATTR_TEMPERATURE: 68.0, # Fahrenheit + ATTR_CURRENT_TEMPERATURE: 65.0, + } + + hass.config.units = US_CUSTOMARY_SYSTEM + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Target and current temperatures are converted from F to C for HomeKit + assert acc.char_heat.value == 20.0 # 68F + assert acc.char_cool.value == 20.0 # 68F + assert acc.char_current_temp.value == pytest.approx(18.3, abs=0.1) # 65F + + +async def test_heatercooler_fahrenheit_default_temp_range( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the Celsius default range is not reconverted in Fahrenheit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No min/max temp, so the Celsius defaults (7/35) are used as-is + ATTR_TEMPERATURE: 68.0, + } + + hass.config.units = US_CUSTOMARY_SYSTEM + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # The default bounds stay 7/35 C rather than being misread as Fahrenheit + assert acc.char_cool.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cool.properties[PROP_MAX_VALUE] == 35.0 + + +async def test_heatercooler_state_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test state updates from Home Assistant to HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test heating mode + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 19.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_HEAT + assert acc.char_current_state.value == HC_HEATING + assert acc.char_heat.value == 22.0 + assert acc.char_cool.value == 22.0 + assert acc.char_current_temp.value == 19.0 + + # Test cooling mode + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.COOLING, + ATTR_TEMPERATURE: 18.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_COOL + assert acc.char_current_state.value == HC_COOLING + assert acc.char_heat.value == 18.0 + assert acc.char_cool.value == 18.0 + + # Test auto mode + hass.states.async_set( + entity_id, + HVACMode.AUTO, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_TEMPERATURE: 20.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_AUTO + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_dual_temperature_support( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with dual temperature support (TARGET_TEMP_HIGH/LOW).""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cool.value == 24.0 # TARGET_TEMP_HIGH + assert acc.char_heat.value == 18.0 # TARGET_TEMP_LOW + + # Update temperatures + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 26.0, + ATTR_TARGET_TEMP_LOW: 16.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_cool.value == 26.0 + assert acc.char_heat.value == 16.0 + + +async def test_heatercooler_fan_speed_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan speed updates.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_FAN_MODE: FAN_LOW, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test different fan speeds + fan_speed_tests = [ + (FAN_LOW, 25), # low -> 25% (index 0) + (FAN_MIDDLE, 50), # middle -> 50% (index 1) + (FAN_MEDIUM, 75), # medium -> 75% (index 2) + (FAN_HIGH, 100), # high -> 100% (index 3) + ] + + for fan_mode, expected_percentage in fan_speed_tests: + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_FAN_MODE: fan_mode} + ) + await hass.async_block_till_done() + assert acc.char_speed.value == expected_percentage + + +async def test_heatercooler_no_swing_toggle_without_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the swing toggle is not exposed without an advertised off mode.""" + entity_id = "climate.test" + # Routed through the fan speeds; the swing list has no off mode, so + # the toggle's off write would be rejected by the entity + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_HIGH], + ATTR_SWING_MODES: ["vertical"], + ATTR_SWING_MODE: "vertical", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.swing_on_mode is None + serv = acc.get_service(SERV_HEATER_COOLER) + assert serv.get_characteristic(CHAR_ROTATION_SPEED) is not None + with pytest.raises(ValueError): + serv.get_characteristic(CHAR_SWING_MODE) + + +async def test_heatercooler_swing_mode_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode updates.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test swing mode on/off + swing_tests = [ + ("off", 0), + ("vertical", 1), + ("horizontal", 1), + ("both", 1), + ] + + for swing_mode, expected_value in swing_tests: + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_SWING_MODE: swing_mode} + ) + await hass.async_block_till_done() + assert acc.char_swing.value == expected_value + + +async def test_heatercooler_unavailable_states( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test handling of unavailable and unknown states.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test unavailable state + hass.states.async_set(entity_id, STATE_UNAVAILABLE, base_attrs) + await hass.async_block_till_done() + + # Manually trigger state update since the test might not automatically trigger callbacks + unavailable_state = hass.states.get(entity_id) + acc.async_update_state(unavailable_state) + + assert acc.char_active.value == 0 + + # Test unknown state + hass.states.async_set(entity_id, STATE_UNKNOWN, base_attrs) + await hass.async_block_till_done() + + unknown_state = hass.states.get(entity_id) + acc.async_update_state(unknown_state) + + assert acc.char_active.value == 0 + + +async def test_heatercooler_action_derivation( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test action derivation when hvac_action is not provided.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, # 2 degrees below target + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Should derive heating action (current < target - delta) + assert acc.char_current_state.value == HC_HEATING + + # Test cooling derivation + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_CURRENT_TEMPERATURE: 22.0, # 2 degrees above target + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_state.value == HC_COOLING + + # Test idle state (within delta) + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_CURRENT_TEMPERATURE: 20.1, # Within 0.25 delta + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_set_active_off( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting active to off via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Turning off alongside a temperature write should only set the OFF mode + _write_chars( + hk_driver, acc, {CHAR_ACTIVE: 0, CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0} + ) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.OFF + assert len(call_set_temperature) == 0 + + +async def test_heatercooler_set_active_off_no_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning off an entity without an OFF mode issues no service call.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL], # no OFF mode + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc._supports_off is False + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + # The rejected write must not leave HomeKit showing the unit as off + assert acc.char_active.value == 1 + + +async def test_heatercooler_unsupported_target_mode_write_restored( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target mode write restores the characteristic.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_COOL + + # Heat is not supported; the write must not reach Home Assistant and + # the characteristic must return to the cool target + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_unsupported_target_write_dry_only_entity( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the restore falls back to the default target for dry only entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.DRY, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Dry has no target representation, so the last known mode cannot be + # restored; the write must fall back to the default target instead of + # leaving the unsupported value on the tile + assert acc.char_target_state.value == HC_TARGET_AUTO + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + assert acc.char_target_state.value == HC_TARGET_AUTO + + +async def test_heatercooler_set_active_on( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting active to on via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + # Start in OFF state + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Set last known mode for test + acc._last_known_mode = HVACMode.HEAT + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + + # Set active to 1 (on) when currently off + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_set_active_on_heat_only( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning a heat-only entity on uses a supported mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Off at startup must fall back to a supported mode, not COOL + assert acc._last_known_mode == HVACMode.HEAT + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_power_on_with_thresholds_uses_activated_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test thresholds batched with Active on resolve against the new mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ), + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + ATTR_TEMPERATURE: None, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + # Power on restores heat cool, so the thresholds go out as a range + # write instead of a single setpoint picked from the off state + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 26.0, abs=0.1 + ) + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 16.0, abs=0.1 + ) + + +async def test_heatercooler_double_off_does_not_corrupt_mode_memory( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test repeated off writes never make off the restore mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The entity accepts the calls but reports its state late + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + + assert acc._last_known_mode == HVACMode.COOL + + # Turning back on works even though the state still reads cool, since + # the pending off write means the entity is about to stop running + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + + +async def test_heatercooler_no_off_entity_applies_rest_of_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected off write does not drop the bundled writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL], # no off + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 0, + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0, + }, + ) + await hass.async_block_till_done() + + # The off is rejected, so the entity keeps running and the bundled + # mode and setpoint writes still apply + assert acc.char_active.value == 1 + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 21.0, abs=0.1 + ) + + +async def test_heatercooler_pending_mode_does_not_mask_target_reject( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target is rejected while a mode is pending.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Turning on leaves heat pending; the entity reports late + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + # Cool is not supported, so the characteristic is put back on heat + # instead of the pending mode masking the rejection + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_HEAT + + +async def test_heatercooler_range_only_one_sided_entity( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a range only entity with one sided modes gets range writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Both sides exist so the write can carry the full range + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 21.0, abs=0.1 + ) + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 22.0, abs=0.1 + ) + assert ATTR_TEMPERATURE not in call_set_temperature[0].data + + +async def test_heatercooler_state_callback_during_write_wins( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a state reported during the call beats the queued mode values.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The entity normalizes the requested mode and reports it before the + # blocking service call returns, like a synchronous integration + async def _hvac(call: ServiceCall) -> None: + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _hvac) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + # The reported mode is fresher than the queued cool, so it stays + assert acc._pending_mode is None + assert acc._last_known_mode == HVACMode.HEAT_COOL + + +async def test_heatercooler_pending_mode_bridges_slow_state_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an accepted mode stays effective until the entity reports it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The mode service is accepted but the entity state is not updated, + # like a push integration that reports later + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + # A threshold batch resolves against the accepted cool mode, not the + # stale heat state, so the cooling side is not dropped + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + + # A mid transition update still reporting the old mode keeps the + # bridge, the displayed target, and the restore mode + hass.states.async_set( + entity_id, HVACMode.HEAT, {**base_attrs, ATTR_CURRENT_TEMPERATURE: 23.0} + ) + await hass.async_block_till_done() + assert acc._pending_mode == HVACMode.COOL + assert acc.char_target_state.value == HC_TARGET_COOL + assert acc._last_known_mode == HVACMode.COOL + + # Once the entity reports a mode change, its state is authoritative again + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + assert acc._pending_mode is None + + +async def test_heatercooler_batch_resolves_after_prior_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a batch sees the mode applied by the batch before it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + gate = asyncio.Event() + + async def _slow_hvac(call: ServiceCall) -> None: + await gate.wait() + hass.states.async_set(entity_id, call.data[ATTR_HVAC_MODE], base_attrs) + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _slow_hvac) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # The threshold batch arrives while the mode switch is still pending; + # it must resolve against COOL, not the stale HEAT state + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + gate.set() + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + + +async def test_heatercooler_failed_write_aborts_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected mode write aborts the rest of the batch.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_FAN_MODE: "low", + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + async_mock_service( + hass, + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + raise_exception=HomeAssistantError("mode rejected"), + ) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_ROTATION_SPEED: 100, + }, + ) + await hass.async_block_till_done() + + # The rejected mode change stops the temperature and fan writes + assert len(call_set_temperature) == 0 + assert len(call_set_fan_mode) == 0 + + +async def test_heatercooler_write_batches_are_serialized( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a second batch waits for the first to finish.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + order: list[str] = [] + gate = asyncio.Event() + + async def _slow_hvac(call: ServiceCall) -> None: + order.append(f"hvac:{call.data[ATTR_HVAC_MODE]}") + if len(order) == 1: + await gate.wait() + + async def _temp(call: ServiceCall) -> None: + order.append("temp") + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _slow_hvac) + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, _temp) + + # The first batch blocks in its mode write while the second arrives + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + }, + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + gate.set() + await hass.async_block_till_done() + + # The first batch finishes its temperature write before the second + # batch's mode write starts + assert order == ["hvac:cool", "temp", "hvac:heat"] + + +async def test_heatercooler_set_chars_dispatch_order( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan writes are dispatched after the mode and temperature writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_FAN_MODE: "low", + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + services: list[str] = [] + + async def _record_and_wait( + domain: str, service: str, data: dict[str, Any], value: Any | None = None + ) -> bool: + services.append(service) + return True + + with patch.object(acc, "async_call_service_and_wait", _record_and_wait): + # Turning on activates heat, so the heating threshold applies + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_ROTATION_SPEED: 100, + }, + ) + await hass.async_block_till_done() + + # Fan is dispatched after both the hvac mode and the temperature write + assert services.index(SERVICE_SET_FAN_MODE) > services.index(SERVICE_SET_HVAC_MODE) + assert services.index(SERVICE_SET_FAN_MODE) > services.index( + SERVICE_SET_TEMPERATURE + ) + + +async def test_heatercooler_set_target_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting target heater cooler state via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + + # Test setting different target modes + mode_tests = [ + (HC_TARGET_HEAT, HVACMode.HEAT), + (HC_TARGET_COOL, HVACMode.COOL), + (HC_TARGET_AUTO, HVACMode.AUTO), + ] + + for hk_mode, _expected_ha_mode in mode_tests: + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: hk_mode}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 3 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.COOL + assert call_set_hvac_mode[2].data[ATTR_HVAC_MODE] == HVACMode.AUTO + + +async def test_heatercooler_set_temperature_single( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting temperature for single-temperature entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Set heating temperature in HEAT mode + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 22.0 + + # Change to COOL mode and set cooling temperature + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 2 + assert call_set_temperature[1].data[ATTR_TEMPERATURE] == 18.0 + + +async def test_heatercooler_set_temperature_dual( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting temperature for dual-temperature entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Set both temperatures + _write_chars( + hk_driver, + acc, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 26.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 16.0 + + +async def test_heatercooler_dual_capable_entity_in_single_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test dual capable entities in a single setpoint mode use temperature.""" + entity_id = "climate.test" + # Entities like ecobee publish the range keys even in heat or cool + # mode, where only the single setpoint carries a value + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ), + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: None, + ATTR_TARGET_TEMP_LOW: None, + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The single setpoint is displayed instead of stale placeholder values + assert acc.char_heat.value == pytest.approx(22.0, abs=0.1) + + # A threshold write in heat mode sends the single setpoint + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 21.0, abs=0.1 + ) + assert ATTR_TARGET_TEMP_HIGH not in call_set_temperature[0].data + + # Switching to Auto in the same batch sends a range write instead + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_AUTO, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert call_set_temperature[-1].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 26.0, abs=0.1 + ) + assert call_set_temperature[-1].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 16.0, abs=0.1 + ) + + +async def test_heatercooler_range_keys_without_range_capability( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cool only entity reporting range keys still gets single writes.""" + entity_id = "climate.test" + # A contradictory device config: range keys without any range mode + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Only the cooling threshold exists, so a range write is impossible + # and the setpoint goes out as a single temperature + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + assert ATTR_TARGET_TEMP_HIGH not in call_set_temperature[0].data + + +async def test_heatercooler_set_fan_speed( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting fan speed via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_FAN_MODE: FAN_LOW, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # A None mode means the write is ignored and dispatches no service call + speed_tests = [ + (0, None), + (25, FAN_LOW), + (50, FAN_MIDDLE), + (75, FAN_MEDIUM), + (100, FAN_HIGH), + ] + + for speed_percent, _ in speed_tests: + _write_chars(hk_driver, acc, {CHAR_ROTATION_SPEED: speed_percent}) + await hass.async_block_till_done() + + expected_calls = [mode for _, mode in speed_tests if mode is not None] + assert len(call_set_fan_mode) == len(expected_calls) + for call, expected_mode in zip(call_set_fan_mode, expected_calls, strict=True): + assert call.data[ATTR_FAN_MODE] == expected_mode + + +async def test_heatercooler_set_swing_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting swing mode via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test swing on + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 1}) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 1 + assert call_set_swing_mode[0].data[ATTR_SWING_MODE] == "both" # swing_on_mode + + # Test swing off + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 0}) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 2 + assert call_set_swing_mode[1].data[ATTR_SWING_MODE] == "off" # SWING_OFF + + +async def test_heatercooler_capitalized_fan_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test capitalized fan modes are sent back to the service unchanged.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["Auto", "Low", "Medium", "High"], + ATTR_FAN_MODE: "Low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # The auto mode puts the speed on the fan service; the rotation speed + # writes must use the entity's original casing + acc.char_speed.client_update_value(100) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "High" + + acc.char_speed.client_update_value(25) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "Low" + + +async def test_heatercooler_capitalized_swing_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test capitalized swing modes are detected and preserved.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["Off", "On", "Both"], + ATTR_SWING_MODE: "Off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Predefined swing modes are detected despite the capitalization + assert acc.swing_on_mode == "On" + assert acc.swing_off_mode == "Off" + + # On/off writes preserve the entity's original casing + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 1}) + await hass.async_block_till_done() + assert call_set_swing_mode[0].data[ATTR_SWING_MODE] == "On" + + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 0}) + await hass.async_block_till_done() + assert call_set_swing_mode[1].data[ATTR_SWING_MODE] == "Off" + + # A capitalized current swing value still reads as on + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_SWING_MODE: "On"} + ) + await hass.async_block_till_done() + assert acc.char_swing.value == 1 + + +async def test_heatercooler_swing_mode_fallback( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode fallback when no swing modes are available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: [], # Empty swing modes + ATTR_SWING_MODE: None, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test setting swing mode off when no swing modes are available + # This should not make a service call because there are no swing modes + acc._set_swing_mode(0) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 0 + + +async def test_heatercooler_fan_speed_no_fan_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan speed handling when no fan modes are available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No fan modes + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # Test setting fan speed when no fan modes are available + acc._set_fan_speed(50) + await hass.async_block_till_done() + + # No service call should be made + assert len(call_set_fan_mode) == 0 + + +async def test_heatercooler_swing_mode_no_swing_attribute( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode handling when swing_on_mode attribute is not available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No swing mode support + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test setting swing mode when swing_on_mode attribute is not available + acc._set_swing_mode(1) + await hass.async_block_till_done() + + # No service call should be made + assert len(call_set_swing_mode) == 0 + + +async def test_heatercooler_single_temp_no_entity_state( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test single temperature handling when entity state is not available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Remove entity state; a threshold write must not call the service + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 0 + + +@pytest.mark.parametrize( + ("state", "current_temp", "chars", "expected"), + [ + # Cool mode uses the cooling threshold; heat mode uses the heating one. + pytest.param( + HVACMode.COOL, + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="cool_uses_cooling_threshold", + ), + pytest.param( + HVACMode.HEAT, + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="heat_uses_heating_threshold", + ), + # HEAT_COOL with both thresholds picks the one further from the temp. + pytest.param( + HVACMode.HEAT_COOL, + 19.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 25.0, # |25-19| > |18-19| + id="heat_cool_picks_further_cooling", + ), + pytest.param( + HVACMode.HEAT_COOL, + 24.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 18.0, # |18-24| > |25-24| + id="heat_cool_picks_further_heating", + ), + # AUTO behaves like HEAT_COOL for a single set point. + pytest.param( + HVACMode.AUTO, + 24.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 18.0, # |18-24| > |25-24| + id="auto_picks_further_heating", + ), + pytest.param( + HVACMode.AUTO, + 19.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 25.0, # |25-19| > |18-19| + id="auto_picks_further_cooling", + ), + # HEAT_COOL with a single threshold falls back to it. + pytest.param( + HVACMode.HEAT_COOL, + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="heat_cool_single_cooling", + ), + pytest.param( + HVACMode.HEAT_COOL, + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="heat_cool_single_heating", + ), + # An unknown mode falls back to whichever threshold was written. + pytest.param( + "unknown_mode", + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="unknown_mode_cooling", + ), + pytest.param( + "unknown_mode", + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="unknown_mode_heating", + ), + ], +) +async def test_heatercooler_complex_temperature_selection( + hass: HomeAssistant, + hk_driver: HomeDriver, + state: str, + current_temp: float, + chars: dict[str, float], + expected: float, +) -> None: + """Test single set point selection driven by threshold characteristic writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.OFF, + ], + ATTR_TEMPERATURE: current_temp, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + hass.states.async_set(entity_id, state, base_attrs) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, chars) + await hass.async_block_till_done() + + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + expected, abs=0.1 + ) + + +@pytest.mark.parametrize( + "state", + [STATE_UNKNOWN, STATE_UNAVAILABLE, "invalid_mode", HVACMode.AUTO], +) +async def test_heatercooler_target_state_unchanged_for_unusable_states( + hass: HomeAssistant, hk_driver: HomeDriver, state: str +) -> None: + """Test the target state is left unchanged for states with no mapping.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # A known Cool target is established from the current state + assert acc.char_target_state.value == HC_TARGET_COOL + + # Unknown, unavailable, invalid, and unsupported Auto have no mapping, so + # the target characteristic keeps its previous value. + hass.states.async_set(entity_id, state, base_attrs) + await hass.async_block_till_done() + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_derive_action_edge_case( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the derived current state for modes without a heating/cooling action.""" + entity_id = "climate.test" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.OFF, + ] + base_attrs = { + ATTR_SUPPORTED_FEATURES: features, + ATTR_HVAC_MODES: hvac_modes, + ATTR_TEMPERATURE: 21.0, + ATTR_CURRENT_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Dry and fan modes have no heating/cooling action, so the state is idle + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + hass.states.async_set(entity_id, HVACMode.FAN_ONLY, base_attrs) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # Cool mode without a target temperature cannot derive an action + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + ATTR_SUPPORTED_FEATURES: features, + ATTR_HVAC_MODES: hvac_modes, + ATTR_CURRENT_TEMPERATURE: 20.0, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # Heat mode already at temperature is idle, not heating + hass.states.async_set( + entity_id, + HVACMode.HEAT, + {**base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 21.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_heat_cool_no_current_temp_diff( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HEAT_COOL mode temperature selection when no current temperature available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + # No current temperature + } + + # Create entity first + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + # HEAT_COOL with both thresholds but no current temp defaults to heating + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + _write_chars( + hk_driver, + acc, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + ) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 18.0, abs=0.1 + ) + + +async def test_heatercooler_derive_action_auto_without_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test auto mode action derivation without a target temperature range.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Without any setpoint the derived state is idle + hass.states.async_set( + entity_id, HVACMode.AUTO, {**base_attrs, ATTR_CURRENT_TEMPERATURE: 21.1} + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # A single setpoint drives both sides of the hysteresis band + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 23.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_COOLING + + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 19.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_HEATING + + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 21.1}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_off_at_startup_activates_displayed_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning on an off-at-startup entity activates the displayed mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The tile shows Auto, so turning on must activate Auto, not the first mode. + assert acc.char_target_state.value == HC_TARGET_AUTO + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.AUTO + + +@pytest.mark.parametrize( + "mode_sequence", + [ + pytest.param( + [HVACMode.COOL, HVACMode.DRY, HVACMode.OFF], id="mode_change_to_dry" + ), + pytest.param([HVACMode.DRY, HVACMode.OFF], id="starts_in_dry"), + ], +) +async def test_heatercooler_unrepresentable_mode_not_restored( + hass: HomeAssistant, hk_driver: HomeDriver, mode_sequence: list[HVACMode] +) -> None: + """Test a mode without a HomeKit target is not restored by Active.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.COOL, + HVACMode.DRY, + HVACMode.OFF, + ], + } + + hass.states.async_set(entity_id, mode_sequence[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + for mode in mode_sequence[1:]: + hass.states.async_set(entity_id, mode, base_attrs) + await hass.async_block_till_done() + + # Dry has no HomeKit target, so the tile shows Cool and turning + # Active on must restore Cool, not Dry. + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + + +async def test_heatercooler_power_on_restores_last_active_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test going off keeps the last mode on the tile and power-on restores it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_HEAT + + # Going off keeps the Heat target on the tile rather than flipping to Auto. + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + assert acc.char_target_state.value == HC_TARGET_HEAT + + # Turning back on restores the mode the tile is showing. + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_cool_mode_ignores_heating_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a heating-threshold write is ignored for a single-setpoint cool entity.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + # Cool mode uses the cooling threshold; a heating-threshold write is ignored. + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + assert len(call_set_temperature) == 0 + + +async def test_heatercooler_batched_mode_decides_setpoint_side( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a mode written in the same batch decides the setpoint side.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # The entity is still heating, but the batch switches to Cool, so the + # cooling threshold is the setpoint rather than being ignored. + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 24.0, + }, + ) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 24.0, abs=0.1 + ) + + +async def test_heatercooler_fan_only_target_falls_back( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a fan-only entity maps Auto to its mode and hides the thresholds.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + ATTR_HVAC_MODES: [HVACMode.FAN_ONLY, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.FAN_ONLY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # No heat/cool/range mode, so Auto maps to the first supported mode + valid_values = acc.char_target_state.properties["ValidValues"] + assert valid_values == {HVACMode.FAN_ONLY: HC_TARGET_AUTO} + assert acc.char_target_state.value == HC_TARGET_AUTO + + # No target temperature support, so the threshold sliders are not exposed + assert not hasattr(acc, "char_cool") + assert not hasattr(acc, "char_heat") + assert acc.char_speed.value == 100 + + +async def test_heatercooler_off_only_target_falls_back_to_off( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a degenerate off-only entity maps Auto to off, not an unsupported mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + ATTR_HVAC_MODES: [HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The only mode is off, so Auto maps to off rather than an unsupported Auto + valid_values = acc.char_target_state.properties["ValidValues"] + assert valid_values == {HVACMode.OFF: HC_TARGET_AUTO} + + +async def test_heatercooler_derive_action_cooling_triggered( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test _derive_action returns COOLING for auto mode when temp is significantly higher than target.""" + entity_id = "climate.test" + + # Test entity in auto mode with current temp higher than target + delta + # Target will be ATTR_TARGET_TEMP_HIGH (20.0), current temp should be > target + 0.25 (delta) + hass.states.async_set( + entity_id, + HVACMode.AUTO, + { + ATTR_HVAC_MODES: [HVACMode.AUTO, HVACMode.OFF], + ATTR_CURRENT_TEMPERATURE: 22.0, # 2°C higher than target + ATTR_TARGET_TEMP_HIGH: 20.0, # Target (becomes the target in _derive_action) + # Note: No ATTR_HVAC_ACTION so _derive_action gets called + }, + ) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "HeaterCooler", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Current 22°C is above target 20°C plus the hysteresis band, so the + # derived action is cooling. + assert acc.char_current_state.value == HC_COOLING + + +async def test_heatercooler_zero_min_temp_preserved( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a reported min temperature of 0 is used, not the default floor.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_MIN_TEMP: 0.0, + ATTR_MAX_TEMP: 30.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_cool.properties[PROP_MIN_VALUE] == 0.0 + + +async def test_heatercooler_reports_humidity_via_linked_sensor( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a reported current humidity is exposed via a linked sensor.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_CURRENT_HUMIDITY: 55, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 55 + + # Every service carries an explicit primary flag so the Home app shows + # the HeaterCooler tile, not the linked humidity sensor + serv = acc.get_service(SERV_HEATER_COOLER) + assert serv.is_primary_service is True + humidity_serv = acc.get_service(SERV_HUMIDITY_SENSOR) + assert humidity_serv.is_primary_service is False + assert humidity_serv.get_characteristic(CHAR_NAME).value == "Climate Humidity" + + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_CURRENT_HUMIDITY: 60} + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + + +async def test_heatercooler_custom_fan_modes_no_rotation_speed( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test entities with only custom fan modes skip the rotation speed char.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # Non-standard names that do not intersect the predefined speeds + ATTR_FAN_MODES: ["quiet", "turbo"], + ATTR_FAN_MODE: "quiet", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + # This must not raise ZeroDivisionError during init + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.ordered_fan_speeds == [] + assert not hasattr(acc, "char_speed") + + +async def test_heatercooler_custom_swing_modes_no_swing_char( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test entities with only custom swing modes skip the swing char.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "custom"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.swing_on_mode is None + assert not hasattr(acc, "char_swing") + + +async def test_heatercooler_derive_action_fahrenheit( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test derived action uses a unit independent hysteresis band.""" + entity_id = "climate.test" + hass.config.units = US_CUSTOMARY_SYSTEM + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # hvac_action intentionally omitted so the action is derived + ATTR_TEMPERATURE: 68.0, + ATTR_CURRENT_TEMPERATURE: 72.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # 72F current vs 68F target in cool mode is well past the 0.25C band + assert acc.char_current_state.value == HC_COOLING + + # Within the hysteresis band (0.2F ~= 0.11C < 0.25C) stays idle + hass.states.async_set( + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_TEMPERATURE: 68.0, ATTR_CURRENT_TEMPERATURE: 68.2}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_derive_action_auto_with_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test derived action in auto mode using the target temperature range.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO, HVACMode.OFF], + # hvac_action intentionally omitted so the action is derived, and no + # ATTR_TEMPERATURE so it falls back to the thresholds. + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 26.0, + } + + hass.states.async_set(entity_id, HVACMode.AUTO, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # 26°C is above the high threshold, so the derived action is cooling + assert acc.char_current_state.value == HC_COOLING + + # Below the low threshold the derived action is heating + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_CURRENT_TEMPERATURE: 18.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_HEATING + + # Comfortably between the thresholds it stays idle, not heating + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_CURRENT_TEMPERATURE: 22.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_auto_fan_mode_linked_fan_service( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an auto fan mode exposes the fan through a linked fan service.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "low", "high"], + ATTR_FAN_MODE: "auto", + ATTR_HVAC_ACTION: HVACAction.COOLING, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The rotation speed lives on the fan service, not the HeaterCooler + assert CHAR_TARGET_FAN_STATE in acc.fan_chars + assert CHAR_ROTATION_SPEED in acc.fan_chars + assert CHAR_CURRENT_FAN_STATE in acc.fan_chars + assert acc.char_target_fan_state.value == 1 + assert acc.char_current_fan_state.value == FAN_STATE_ACTIVE + assert acc.char_fan_active.value == 1 + + # Leaving auto selects the middle manual speed; re-enabling restores auto + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_target_fan_state.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "low" + + acc.char_target_fan_state.client_update_value(1) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "auto" + + # A manual fan mode is reflected back into the fan service chars + hass.states.async_set( + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_FAN_MODE: "high", ATTR_HVAC_ACTION: HVACAction.IDLE}, + ) + await hass.async_block_till_done() + assert acc.char_target_fan_state.value == 0 + assert acc.char_speed.value == 100 + assert acc.char_current_fan_state.value == FAN_STATE_IDLE + + # Turning the unit off turns the fan inactive + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + assert acc.char_fan_active.value == 0 + + # An entity that is unavailable when the accessory is created starts + # with the fan inactive; state change propagation filters unavailable + # in the base accessory, so only the creation path sees it. + hass.states.async_set(entity_id, STATE_UNAVAILABLE, base_attrs) + await hass.async_block_till_done() + acc_unavailable = HeaterCooler(hass, hk_driver, "Climate", entity_id, 2, None) + assert acc_unavailable.char_fan_active.value == 0 + + +@pytest.mark.parametrize( + ("hvac_modes", "expected_category"), + [ + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="cooling_capable", + ), + pytest.param( + [HVACMode.HEAT_COOL, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="heat_cool", + ), + pytest.param( + [HVACMode.HEAT, HVACMode.OFF], + CATEGORY_HEATER, + id="heat_only", + ), + pytest.param( + [HVACMode.DRY, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="dry_only_is_not_a_heater", + ), + ], +) +async def test_heatercooler_category( + hass: HomeAssistant, + hk_driver: HomeDriver, + hvac_modes: list[HVACMode], + expected_category: int, +) -> None: + """Test the advertised category matches the device capabilities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: hvac_modes, + } + + hass.states.async_set(entity_id, hvac_modes[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + assert acc.category == expected_category + + +async def test_heatercooler_auto_fan_service_without_speeds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test auto plus on fan modes get a fan service without a speed slider.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "on"], + ATTR_FAN_MODE: "on", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert CHAR_TARGET_FAN_STATE in acc.fan_chars + assert CHAR_ROTATION_SPEED not in acc.fan_chars + assert not hasattr(acc, "char_speed") + assert acc.char_target_fan_state.value == 0 + + # Leaving auto falls back to the on mode + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_target_fan_state.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "on" + + +async def test_heatercooler_fan_active_resets_without_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a fan off write resets to on when the fan has no off mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "low", "high"], + ATTR_FAN_MODE: "low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_fan_active.client_update_value(0) + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 0 + assert acc.char_fan_active.value == 1 + + +async def test_heatercooler_fan_active_toggles_with_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the fan active toggle maps to the fan off and on modes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "off", "low", "high"], + ATTR_FAN_MODE: "low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_fan_active.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "off" + + acc.char_fan_active.client_update_value(1) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "low" + + +async def test_heatercooler_cool_only_single_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cool-only entity exposes only the cooling threshold.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert not hasattr(acc, "char_heat") + assert acc.char_cool.value == pytest.approx(22.0, abs=0.1) + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 24.0}) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 24.0, abs=0.1 + ) + + +async def test_heatercooler_heat_only_single_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a heat-only entity exposes only the heating threshold.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert not hasattr(acc, "char_cool") + assert acc.char_heat.value == pytest.approx(21.0, abs=0.1) + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 19.0}) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 19.0, abs=0.1 + ) + + +async def test_heatercooler_dry_only_keeps_both_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a dry-only entity with a setpoint keeps both thresholds.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.DRY, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical"], + ATTR_TEMPERATURE: 23.0, + } + + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Neither side is capable, so both stay to keep the setpoint controllable + assert acc.char_cool.value == pytest.approx(23.0, abs=0.1) + assert acc.char_heat.value == pytest.approx(23.0, abs=0.1) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index a311ef1ae21b..32c83e76715d 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1000,7 +1000,20 @@ async def test_thermostat_get_temperature_range(hass: HomeAssistant, hk_driver) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state - assert acc.get_temperature_range(state) == (15.5, 21.0) + # 60F is 15.56C, rounded inward to the 0.1 step so the slider cannot + # go below the entity's own minimum + assert acc.get_temperature_range(state) == (15.6, 21.1) + + # A range too narrow to hold a step keeps the exact limits instead + # of expanding beyond them + acc._unit = UnitOfTemperature.CELSIUS + hass.states.async_set( + entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 20.11, ATTR_MAX_TEMP: 20.14} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (20.11, 20.14) async def test_thermostat_temperature_step_whole( @@ -1973,7 +1986,9 @@ async def test_water_heater_get_temperature_range( state = hass.states.get(entity_id) assert state await hass.async_block_till_done() - assert acc.get_temperature_range(state) == (15.5, 21.0) + # 60F is 15.56C, rounded inward to the 0.1 step so the slider cannot + # go below the entity's own minimum + assert acc.get_temperature_range(state) == (15.6, 21.1) async def test_water_heater_restore( diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index b5e273237a3d..05d09b89848a 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -47,10 +47,12 @@ from homeassistant.components.homekit.const import ( FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from homeassistant.components.homekit.models import HomeKitEntryData @@ -140,6 +142,7 @@ def test_validate_entity_config() -> None: } }, {"fan.test": {CONF_TYPE: "invalid_type"}}, + {"climate.test": {CONF_TYPE: "invalid_type"}}, { "valve.test": { # Must be sensor (timestamp) entity @@ -236,6 +239,12 @@ def test_validate_entity_config() -> None: assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == { "switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20} } + assert vec({"climate.demo": {CONF_TYPE: TYPE_HEATER_COOLER}}) == { + "climate.demo": {CONF_TYPE: TYPE_HEATER_COOLER, CONF_LOW_BATTERY_THRESHOLD: 20} + } + assert vec({"climate.demo": {CONF_TYPE: TYPE_THERMOSTAT}}) == { + "climate.demo": {CONF_TYPE: TYPE_THERMOSTAT, CONF_LOW_BATTERY_THRESHOLD: 20} + } config = { CONF_TYPE: TYPE_SPRINKLER, CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", From db07a66c6074b5ff6dff86ebdc97950eeb9d1d66 Mon Sep 17 00:00:00 2001 From: Manu Date: Fri, 10 Jul 2026 20:35:51 +0200 Subject: [PATCH 448/707] Add subentry reconfigure flow to SMTP integration (#176152) --- homeassistant/components/smtp/config_flow.py | 52 ++++++- homeassistant/components/smtp/strings.json | 13 +- tests/components/smtp/test_config_flow.py | 142 ++++++++++++++++++- 3 files changed, 204 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index b38cfef48c85..f298198f588a 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -10,6 +10,7 @@ from typing import Any, override import voluptuous as vol +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ( SOURCE_USER, ConfigFlow, @@ -33,7 +34,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -359,6 +360,55 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow): ), ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure flow to update a recipient.""" + + entry = self._get_entry() + subentry = self._get_reconfigure_subentry() + + if user_input is not None: + old_unique_id = subentry.unique_id + result = self.async_update_and_abort( + entry, + subentry=subentry, + title=( + user_input[CONF_RECIPIENT] + if subentry.title == old_unique_id + else subentry.title + ), + data_updates={}, + unique_id=user_input[CONF_RECIPIENT], + ) + if result.get("reason") == "reconfigure_successful" and ( + entity := er.async_get(self.hass).async_get_entity_id( + NOTIFY_DOMAIN, DOMAIN, f"{entry.entry_id}_{old_unique_id}" + ) + ): + er.async_get(self.hass).async_update_entity( + entity, + new_unique_id=f"{entry.entry_id}_{user_input[CONF_RECIPIENT]}", + ) + return result + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_RECIPIENT): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ), + ) + } + ), + suggested_values={CONF_RECIPIENT: subentry.unique_id}, + ), + ) + class OptionsFlowHandler(OptionsFlow): """Handle options flow.""" diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 1c6a8cf6c2c5..9908b7c9f521 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -74,13 +74,24 @@ "config_subentries": { "recipient": { "abort": { - "already_configured": "Recipient is already configured" + "already_configured": "Recipient is already configured", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Recipient", "initiate_flow": { "user": "Add recipient" }, "step": { + "reconfigure": { + "data": { + "recipient": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "recipient": "[%key:component::smtp::config_subentries::recipient::step::user::data_description::recipient%]" + }, + "description": "Update the recipient email address.", + "title": "Reconfigure recipient" + }, "user": { "data": { "name": "[%key:common::config_flow::data::name%]", diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index 0767acbe0546..fd8f82d13e21 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -13,7 +13,13 @@ from homeassistant.components.smtp.const import ( DOMAIN, SUBENTRY_TYPE_RECIPIENT, ) -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, FlowType +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigEntryState, + ConfigSubentryData, + FlowType, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -24,6 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er from .conftest import USER_INPUT @@ -442,3 +449,136 @@ async def test_form_reauth_errors( CONF_PASSWORD: "new-password", } assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("smtp") +async def test_form_subentry_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test subentry reconfigure flow.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + assert (entity := entity_registry.async_get("notify.home_assistant_recipient")) + assert entity.unique_id == "123456789_recipient@example.com" + + result = await config_entry.start_subentry_reconfigure_flow(hass, "ABCDEF") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "changed@example.com"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.subentries["ABCDEF"].unique_id == "changed@example.com" + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + assert (entity := entity_registry.async_get("notify.home_assistant_recipient")) + assert entity.unique_id == "123456789_changed@example.com" + + +@pytest.mark.usefixtures("smtp") +async def test_form_subentry_reconfigure_already_configured( + hass: HomeAssistant, +) -> None: + """Test we abort subentry reconfigure flow when already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data=USER_INPUT, + options={ + CONF_TIMEOUT: 5, + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_id="ABCDEF", + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title="Recipient", + unique_id="recipient@example.com", + ), + ConfigSubentryData( + data={}, + subentry_id="GHIJKL", + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title="Recipient2", + unique_id="changed@example.com", + ), + ], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await config_entry.start_subentry_reconfigure_flow(hass, "ABCDEF") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "changed@example.com"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("smtp") +async def test_form_subentry_reconfigure_updates_title( + hass: HomeAssistant, +) -> None: + """Test subentry reconfigure flow updates subentry title if it matches email.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data=USER_INPUT, + options={ + CONF_TIMEOUT: 5, + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_id="ABCDEF", + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title="recipient@example.com", + unique_id="recipient@example.com", + ) + ], + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await config_entry.start_subentry_reconfigure_flow(hass, "ABCDEF") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "changed@example.com"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.subentries["ABCDEF"].unique_id == "changed@example.com" + assert config_entry.subentries["ABCDEF"].title == "changed@example.com" From 5e2694ad3c9d20d05a195c51a1b75efd1a30c4b2 Mon Sep 17 00:00:00 2001 From: Johnny Willemsen Date: Fri, 10 Jul 2026 20:36:43 +0200 Subject: [PATCH 449/707] No need to check for valid_token, also done in called method (#176215) --- homeassistant/components/aladdin_connect/api.py | 3 +-- homeassistant/components/gentex_homelink/oauth2.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py index 1a45247dae8d..e418903edd37 100644 --- a/homeassistant/components/aladdin_connect/api.py +++ b/homeassistant/components/aladdin_connect/api.py @@ -41,7 +41,6 @@ class AsyncConfigEntryAuth(Auth): @override async def async_get_access_token(self) -> str: """Return a valid access token.""" - if not self._oauth_session.valid_token: - await self._oauth_session.async_ensure_token_valid() + await self._oauth_session.async_ensure_token_valid() return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/gentex_homelink/oauth2.py b/homeassistant/components/gentex_homelink/oauth2.py index 894c15378605..027100b7446d 100644 --- a/homeassistant/components/gentex_homelink/oauth2.py +++ b/homeassistant/components/gentex_homelink/oauth2.py @@ -110,7 +110,6 @@ class AsyncConfigEntryAuth(AbstractAuth): async def async_get_access_token(self) -> str: """Return a valid access token.""" - if not self._oauth_session.valid_token: - await self._oauth_session.async_ensure_token_valid() + await self._oauth_session.async_ensure_token_valid() return self._oauth_session.token["access_token"] From c5d86d3eace2d3bef7fbd89cbba8fa41ad4ea3ca Mon Sep 17 00:00:00 2001 From: Herbertmt978 Date: Fri, 10 Jul 2026 19:37:19 +0100 Subject: [PATCH 450/707] Add ScorpionTrack integration (#168737) Co-authored-by: Joostlek --- CODEOWNERS | 2 + .../components/scorpiontrack/__init__.py | 32 +++ .../components/scorpiontrack/config_flow.py | 92 ++++++++ .../components/scorpiontrack/const.py | 15 ++ .../components/scorpiontrack/coordinator.py | 71 +++++++ .../scorpiontrack/device_tracker.py | 76 +++++++ .../components/scorpiontrack/entity.py | 44 ++++ .../components/scorpiontrack/icons.json | 9 + .../components/scorpiontrack/manifest.json | 12 ++ .../scorpiontrack/quality_scale.yaml | 93 ++++++++ .../components/scorpiontrack/strings.json | 36 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/scorpiontrack/__init__.py | 12 ++ tests/components/scorpiontrack/conftest.py | 86 ++++++++ .../snapshots/test_device_tracker.ambr | 60 ++++++ .../scorpiontrack/test_config_flow.py | 199 ++++++++++++++++++ .../scorpiontrack/test_device_tracker.py | 99 +++++++++ tests/components/scorpiontrack/test_init.py | 74 +++++++ 20 files changed, 1022 insertions(+) create mode 100644 homeassistant/components/scorpiontrack/__init__.py create mode 100644 homeassistant/components/scorpiontrack/config_flow.py create mode 100644 homeassistant/components/scorpiontrack/const.py create mode 100644 homeassistant/components/scorpiontrack/coordinator.py create mode 100644 homeassistant/components/scorpiontrack/device_tracker.py create mode 100644 homeassistant/components/scorpiontrack/entity.py create mode 100644 homeassistant/components/scorpiontrack/icons.json create mode 100644 homeassistant/components/scorpiontrack/manifest.json create mode 100644 homeassistant/components/scorpiontrack/quality_scale.yaml create mode 100644 homeassistant/components/scorpiontrack/strings.json create mode 100644 tests/components/scorpiontrack/__init__.py create mode 100644 tests/components/scorpiontrack/conftest.py create mode 100644 tests/components/scorpiontrack/snapshots/test_device_tracker.ambr create mode 100644 tests/components/scorpiontrack/test_config_flow.py create mode 100644 tests/components/scorpiontrack/test_device_tracker.py create mode 100644 tests/components/scorpiontrack/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index b3642514baf1..cedde68ab496 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1586,6 +1586,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/schlage/ @dknowles2 /tests/components/schlage/ @dknowles2 /homeassistant/components/schluter/ @prairieapps +/homeassistant/components/scorpiontrack/ @Herbertmt978 +/tests/components/scorpiontrack/ @Herbertmt978 /homeassistant/components/scrape/ @fabaff @gjohansson-ST /tests/components/scrape/ @fabaff @gjohansson-ST /homeassistant/components/screenlogic/ @dieselrabbit @bdraco diff --git a/homeassistant/components/scorpiontrack/__init__.py b/homeassistant/components/scorpiontrack/__init__.py new file mode 100644 index 000000000000..ebb42ebe8ee1 --- /dev/null +++ b/homeassistant/components/scorpiontrack/__init__.py @@ -0,0 +1,32 @@ +"""The ScorpionTrack integration.""" + +from pyscorpiontrack import ScorpionTrackClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SHARE_TOKEN, PLATFORMS +from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ScorpionTrackConfigEntry +) -> bool: + """Set up ScorpionTrack from a config entry.""" + client = ScorpionTrackClient( + session=async_get_clientsession(hass), + token=entry.data[CONF_SHARE_TOKEN], + ) + coordinator = ScorpionTrackCoordinator(hass, client, entry) + + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: ScorpionTrackConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/scorpiontrack/config_flow.py b/homeassistant/components/scorpiontrack/config_flow.py new file mode 100644 index 000000000000..f680177219f3 --- /dev/null +++ b/homeassistant/components/scorpiontrack/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for ScorpionTrack.""" + +import logging +from typing import Any, override + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SHARE_TOKEN, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _async_validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> ScorpionTrackShare: + """Validate the provided share token or share URL.""" + try: + normalized_token = ScorpionTrackClient.extract_token( + user_input[CONF_SHARE_TOKEN] + ) + except (ScorpionTrackInvalidTokenError, ValueError) as err: + raise ScorpionTrackInvalidTokenError( + "Invalid ScorpionTrack share token" + ) from err + + client = ScorpionTrackClient( + session=async_get_clientsession(hass), + token=normalized_token, + ) + return await client.async_get_share() + + +def _share_title(share: ScorpionTrackShare) -> str: + """Return the best config entry title for a share.""" + if share.title: + return share.title + if share.vehicles: + return share.vehicles[0].display_name + return DEFAULT_NAME + + +class ScorpionTrackConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ScorpionTrack.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + share = await _async_validate_input(self.hass, user_input) + except ScorpionTrackConnectionError: + errors["base"] = "cannot_connect" + except ScorpionTrackInvalidTokenError: + errors["base"] = "invalid_token" + except ScorpionTrackShareUnavailableError: + errors["base"] = "share_unavailable" + except Exception: + _LOGGER.exception( + "Unexpected exception while validating ScorpionTrack share" + ) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(share.id)) + self._abort_if_unique_id_configured() + user_input[CONF_SHARE_TOKEN] = share.token + return self.async_create_entry( + title=_share_title(share), + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_SHARE_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/scorpiontrack/const.py b/homeassistant/components/scorpiontrack/const.py new file mode 100644 index 000000000000..851d745a35bc --- /dev/null +++ b/homeassistant/components/scorpiontrack/const.py @@ -0,0 +1,15 @@ +"""Constants for the ScorpionTrack integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "scorpiontrack" +DEFAULT_NAME = "ScorpionTrack" +MANUFACTURER = "ScorpionTrack" + +CONF_SHARE_TOKEN = "share_token" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=2) + +PLATFORMS: tuple[Platform, ...] = (Platform.DEVICE_TRACKER,) diff --git a/homeassistant/components/scorpiontrack/coordinator.py b/homeassistant/components/scorpiontrack/coordinator.py new file mode 100644 index 000000000000..c8eda3c0a690 --- /dev/null +++ b/homeassistant/components/scorpiontrack/coordinator.py @@ -0,0 +1,71 @@ +"""Coordinator for ScorpionTrack.""" + +import logging +from typing import override + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, + ScorpionTrackVehicle, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type ScorpionTrackConfigEntry = ConfigEntry[ScorpionTrackCoordinator] + + +class ScorpionTrackCoordinator(DataUpdateCoordinator[ScorpionTrackShare]): + """Coordinate shared-location updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: ScorpionTrackClient, + entry: ScorpionTrackConfigEntry, + ) -> None: + """Initialize the coordinator.""" + self.client = client + self.vehicles_by_id: dict[int, ScorpionTrackVehicle] = {} + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{entry.entry_id}", + update_interval=DEFAULT_SCAN_INTERVAL, + always_update=False, + ) + + @override + async def _async_update_data(self) -> ScorpionTrackShare: + """Fetch updated share data.""" + try: + share = await self.client.async_get_share() + except ScorpionTrackConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except ScorpionTrackInvalidTokenError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_token", + ) from err + except ScorpionTrackShareUnavailableError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="share_unavailable", + ) from err + else: + self.vehicles_by_id = {vehicle.id: vehicle for vehicle in share.vehicles} + return share diff --git a/homeassistant/components/scorpiontrack/device_tracker.py b/homeassistant/components/scorpiontrack/device_tracker.py new file mode 100644 index 000000000000..723f7b585f8b --- /dev/null +++ b/homeassistant/components/scorpiontrack/device_tracker.py @@ -0,0 +1,76 @@ +"""Device tracker platform for ScorpionTrack.""" + +from typing import override + +from pyscorpiontrack import ScorpionTrackVehicle + +from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator +from .entity import ScorpionTrackEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ScorpionTrackConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up ScorpionTrack tracker entities.""" + coordinator = entry.runtime_data + async_add_entities( + ScorpionTrackTrackerEntity(coordinator, vehicle.id) + for vehicle in coordinator.data.vehicles + ) + + +class ScorpionTrackTrackerEntity(ScorpionTrackEntity, TrackerEntity): + """Represent the latest shared GPS location for a vehicle.""" + + _attr_name = None + _attr_translation_key = "vehicle_location" + + def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None: + """Initialize the tracker.""" + super().__init__(coordinator, vehicle_id) + self._attr_unique_id = f"{coordinator.data.id}_{vehicle_id}" + + def _available_vehicle(self) -> ScorpionTrackVehicle | None: + """Return the vehicle if the tracker is available.""" + if not super().available: + return None + return self.get_vehicle() + + @property + @override + def available(self) -> bool: + """Return if the tracker is available.""" + vehicle = self._available_vehicle() + if vehicle is None: + return False + + return ( + vehicle.position.latitude is not None + and vehicle.position.longitude is not None + ) + + @property + @override + def latitude(self) -> float | None: + """Return the latitude.""" + vehicle = self._available_vehicle() + if vehicle is None: + return None + return vehicle.position.latitude + + @property + @override + def longitude(self) -> float | None: + """Return the longitude.""" + vehicle = self._available_vehicle() + if vehicle is None: + return None + return vehicle.position.longitude diff --git a/homeassistant/components/scorpiontrack/entity.py b/homeassistant/components/scorpiontrack/entity.py new file mode 100644 index 000000000000..2fa3c597d6ce --- /dev/null +++ b/homeassistant/components/scorpiontrack/entity.py @@ -0,0 +1,44 @@ +"""Shared entity helpers for ScorpionTrack.""" + +from typing import override + +from pyscorpiontrack import ScorpionTrackShare, ScorpionTrackVehicle + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import ScorpionTrackCoordinator + + +class ScorpionTrackEntity(CoordinatorEntity[ScorpionTrackCoordinator]): + """Base class for ScorpionTrack vehicle entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._vehicle_id = vehicle_id + vehicle = self.get_vehicle() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self.share.id}_{vehicle_id}")}, + manufacturer=vehicle.make or MANUFACTURER, + model=vehicle.model, + name=vehicle.display_name, + ) + + @property + def share(self) -> ScorpionTrackShare: + """Return the active share data.""" + return self.coordinator.data + + def get_vehicle(self) -> ScorpionTrackVehicle: + """Return the matching vehicle.""" + return self.coordinator.vehicles_by_id[self._vehicle_id] + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self._vehicle_id in self.coordinator.vehicles_by_id diff --git a/homeassistant/components/scorpiontrack/icons.json b/homeassistant/components/scorpiontrack/icons.json new file mode 100644 index 000000000000..a792c09e859c --- /dev/null +++ b/homeassistant/components/scorpiontrack/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "device_tracker": { + "vehicle_location": { + "default": "mdi:car" + } + } + } +} diff --git a/homeassistant/components/scorpiontrack/manifest.json b/homeassistant/components/scorpiontrack/manifest.json new file mode 100644 index 000000000000..0ced3f6c03d5 --- /dev/null +++ b/homeassistant/components/scorpiontrack/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "scorpiontrack", + "name": "ScorpionTrack", + "codeowners": ["@Herbertmt978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/scorpiontrack", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pyscorpiontrack"], + "quality_scale": "silver", + "requirements": ["pyscorpiontrack==0.1.1"] +} diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml new file mode 100644 index 000000000000..2cacc2339081 --- /dev/null +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-triggers: + status: exempt + comment: This integration does not provide triggers. + docs-conditions: + status: exempt + comment: This integration does not provide conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + ScorpionTrack share links are public tokens rather than account credentials. + Replacing an expired or revoked share requires configuring a new share link. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + Vehicles are exposed through a ScorpionTrack cloud shared-location/API + link generated from the ScorpionTrack website. The trackers are standalone + units that communicate over their SIM/cellular connection rather than + Wi-Fi or the local network, so Home Assistant has no local discovery + mechanism. + discovery-update-info: + status: exempt + comment: | + Vehicles are exposed through a ScorpionTrack cloud shared-location/API + link generated from the ScorpionTrack website. The trackers are standalone + units that communicate over their SIM/cellular connection rather than + Wi-Fi or the local network, so Home Assistant has no local discovery + mechanism. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The created entities are useful for shared vehicle tracking and should be enabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json new file mode 100644 index 000000000000..63595a78ae09 --- /dev/null +++ b/homeassistant/components/scorpiontrack/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "This shared location is already configured." + }, + "error": { + "cannot_connect": "The ScorpionTrack share could not be reached right now.", + "invalid_token": "Your shared-location link is invalid or incomplete.", + "share_unavailable": "Your shared-location link is invalid, expired, has been revoked, or no longer returns data.", + "unknown": "An unexpected error occurred." + }, + "step": { + "user": { + "data": { + "share_token": "Share URL or token" + }, + "data_description": { + "share_token": "Paste the full ScorpionTrack shared-location URL or just the token itself." + }, + "description": "Paste a ScorpionTrack shared-location URL or the token from that URL.", + "title": "Connect a ScorpionTrack share" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "The ScorpionTrack share could not be reached right now." + }, + "invalid_token": { + "message": "ScorpionTrack rejected the configured share token." + }, + "share_unavailable": { + "message": "The shared location is unavailable." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f3c531ae4f15..0d2fea79361d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -669,6 +669,7 @@ FLOWS = { "satel_integra", "saunum", "schlage", + "scorpiontrack", "scrape", "screenlogic", "season", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4957ec3ce51a..6459b2ea5ca1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6185,6 +6185,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "scorpiontrack": { + "name": "ScorpionTrack", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "scrape": { "name": "Scrape", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ed824d5ed04f..37b079fcb578 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2534,6 +2534,9 @@ pysaunum==0.7.0 # homeassistant.components.schlage pyschlage==2025.9.0 +# homeassistant.components.scorpiontrack +pyscorpiontrack==0.1.1 + # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/tests/components/scorpiontrack/__init__.py b/tests/components/scorpiontrack/__init__.py new file mode 100644 index 000000000000..e6917b6c4a9b --- /dev/null +++ b/tests/components/scorpiontrack/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the ScorpionTrack integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the ScorpionTrack integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/scorpiontrack/conftest.py b/tests/components/scorpiontrack/conftest.py new file mode 100644 index 000000000000..efc871fafb79 --- /dev/null +++ b/tests/components/scorpiontrack/conftest.py @@ -0,0 +1,86 @@ +"""Test fixtures for the ScorpionTrack integration.""" + +from collections.abc import Iterator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackPosition, + ScorpionTrackShare, + ScorpionTrackVehicle, +) +import pytest + +from homeassistant.components.scorpiontrack.const import CONF_SHARE_TOKEN, DOMAIN +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_share() -> ScorpionTrackShare: + """Return a representative ScorpionTrack share.""" + now = dt_util.utcnow() + return ScorpionTrackShare( + id=101, + token="canonical-token", + title="Family Cars", + owner_name="Ashby Herbert", + distance_units="miles", + created_at=now - timedelta(days=3), + expires_at=now + timedelta(days=28), + vehicles=( + ScorpionTrackVehicle( + id=1, + name="Golf R", + registration="AB12 CDE", + make="Volkswagen", + model="Golf R", + position=ScorpionTrackPosition( + latitude=51.5074, + longitude=-0.1278, + timestamp=now - timedelta(days=2), + speed_kmh=48.3, + ignition=True, + bearing=182.0, + address="Westminster, London", + ), + status="Moving", + ), + ), + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Family Cars", + data={CONF_SHARE_TOKEN: "canonical-token"}, + unique_id="101", + entry_id="01SCORPIONTRACK_TEST_ENTRY", + ) + + +@pytest.fixture(autouse=True) +def mock_scorpiontrack_client( + mock_share: ScorpionTrackShare, +) -> Iterator[AsyncMock]: + """Mock the ScorpionTrack client.""" + with ( + patch( + "homeassistant.components.scorpiontrack.ScorpionTrackClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient", + new=mock_client, + ), + ): + client = mock_client.return_value + mock_client.extract_token.side_effect = ScorpionTrackClient.extract_token + client.token = "canonical-token" + client.async_get_share.return_value = mock_share + yield client diff --git a/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..3f2df867a0ca --- /dev/null +++ b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_device_tracker_state[device_tracker.ab12_cde-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.ab12_cde', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'scorpiontrack', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_location', + 'unique_id': '101_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_state[device_tracker.ab12_cde-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'AB12 CDE', + : 0, + : list([ + ]), + : 51.5074, + : -0.1278, + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.ab12_cde', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/scorpiontrack/test_config_flow.py b/tests/components/scorpiontrack/test_config_flow.py new file mode 100644 index 000000000000..e970093b7a8c --- /dev/null +++ b/tests/components/scorpiontrack/test_config_flow.py @@ -0,0 +1,199 @@ +"""Test the ScorpionTrack config flow.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +from pyscorpiontrack import ( + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, +) +import pytest + +from homeassistant.components.scorpiontrack.const import ( + CONF_SHARE_TOKEN, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from tests.common import MockConfigEntry + + +async def _async_start_user_flow(hass: HomeAssistant) -> FlowResult: + """Start the user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + return result + + +async def test_user_flow_creates_entry( + hass: HomeAssistant, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """A valid token should create a config entry.""" + result = await _async_start_user_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SHARE_TOKEN: ( + " https://app.scorpiontrack.com/shared/location" + "?token=canonical-token " + ) + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Family Cars" + assert result["data"] == {CONF_SHARE_TOKEN: "canonical-token"} + assert result["result"].unique_id == "101" + mock_scorpiontrack_client.async_get_share.assert_awaited() + + +async def test_user_flow_uses_vehicle_display_name_without_share_title( + hass: HomeAssistant, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """The config entry title should fall back to the first vehicle display name.""" + share = replace(mock_share, title="") + mock_scorpiontrack_client.async_get_share.return_value = share + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == share.vehicles[0].display_name + assert result["result"].unique_id == "101" + + +async def test_user_flow_uses_default_name_without_title_or_vehicles( + hass: HomeAssistant, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """The config entry title should use the default name when nothing descriptive exists.""" + share = replace(mock_share, title="", vehicles=()) + mock_scorpiontrack_client.async_get_share.return_value = share + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["result"].unique_id == "101" + + +async def test_user_flow_aborts_for_existing_share( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """The same share should not be configured twice.""" + mock_config_entry.add_to_hass(hass) + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + ScorpionTrackConnectionError("Connection failed"), + "cannot_connect", + ), + (ScorpionTrackInvalidTokenError("Invalid token"), "invalid_token"), + ( + ScorpionTrackShareUnavailableError("Share expired"), + "share_unavailable", + ), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_user_flow_shows_validation_errors( + hass: HomeAssistant, + mock_scorpiontrack_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Validation errors should surface the correct base error.""" + mock_scorpiontrack_client.async_get_share.side_effect = side_effect + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + +async def test_user_flow_maps_malformed_input_to_invalid_token( + hass: HomeAssistant, +) -> None: + """Malformed pasted values should show the invalid-token form error.""" + with patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient.extract_token", + side_effect=ValueError("Could not parse share link"), + ): + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "not a share"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_token"} + + +async def test_user_flow_recovers_after_invalid_token( + hass: HomeAssistant, +) -> None: + """The user flow should recover after an invalid token error.""" + result = await _async_start_user_flow(hass) + + with patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient.extract_token", + side_effect=ScorpionTrackInvalidTokenError("Invalid token"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "not a share"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_token"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_SHARE_TOKEN: "canonical-token"} + assert result["result"].unique_id == "101" diff --git a/tests/components/scorpiontrack/test_device_tracker.py b/tests/components/scorpiontrack/test_device_tracker.py new file mode 100644 index 000000000000..2dc2ab4c0681 --- /dev/null +++ b/tests/components/scorpiontrack/test_device_tracker.py @@ -0,0 +1,99 @@ +"""Test the ScorpionTrack device tracker platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pyscorpiontrack import ScorpionTrackConnectionError, ScorpionTrackShare +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_device_tracker_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the ScorpionTrack device tracker state and attributes.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_removed_vehicle_becomes_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Test a tracker becomes unavailable if its vehicle leaves the share.""" + await setup_integration(hass, mock_config_entry) + + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, vehicles=() + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.ab12_cde") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_connection_error_makes_tracker_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Test a tracker becomes unavailable after a refresh connection error.""" + await setup_integration(hass, mock_config_entry) + + mock_scorpiontrack_client.async_get_share.side_effect = ( + ScorpionTrackConnectionError("Connection failed") + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.ab12_cde") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_new_vehicles_after_setup_do_not_add_tracker_entities( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Vehicles that appear later should wait for a future dynamic-device PR.""" + await setup_integration(hass, mock_config_entry) + + new_vehicle = replace( + mock_share.vehicles[0], + id=2, + name="Tiguan", + registration="EF34 ABC", + model="Tiguan", + ) + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, vehicles=(*mock_share.vehicles, new_vehicle) + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.ef34_abc") is None diff --git a/tests/components/scorpiontrack/test_init.py b/tests/components/scorpiontrack/test_init.py new file mode 100644 index 000000000000..570e222f072e --- /dev/null +++ b/tests/components/scorpiontrack/test_init.py @@ -0,0 +1,74 @@ +"""Test ScorpionTrack integration setup.""" + +from unittest.mock import AsyncMock + +from pyscorpiontrack import ( + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShareUnavailableError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload of entry.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device_is_registered( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the ScorpionTrack vehicle device is registered.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={("scorpiontrack", "101_1")}) + assert device is not None + assert device.name == "AB12 CDE" + assert device.manufacturer == "Volkswagen" + assert device.model == "Golf R" + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (ScorpionTrackInvalidTokenError("Invalid token"), ConfigEntryState.SETUP_ERROR), + ( + ScorpionTrackShareUnavailableError("Share expired"), + ConfigEntryState.SETUP_ERROR, + ), + ( + ScorpionTrackConnectionError("Connection failed"), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup with token and connection errors.""" + mock_scorpiontrack_client.async_get_share.side_effect = exception + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is expected_state From 00b6cd85c38fe90a5762d6ca2c6a6a72f35eb217 Mon Sep 17 00:00:00 2001 From: Manu Date: Fri, 10 Jul 2026 20:41:07 +0200 Subject: [PATCH 451/707] Use TextSelectorType.EMAIL in SMTP integration (#176212) --- homeassistant/components/smtp/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index f298198f588a..52a7641aa5f2 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -69,7 +69,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_SENDER): TextSelector( TextSelectorConfig( - type=TextSelectorType.TEXT, + type=TextSelectorType.EMAIL, autocomplete="email", ), ), @@ -352,7 +352,7 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow): vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_RECIPIENT): TextSelector( TextSelectorConfig( - type=TextSelectorType.TEXT, + type=TextSelectorType.EMAIL, autocomplete="email", ), ), From 5875c97d7a3b52f36c7e326c7d6d4ec1465710b7 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Sat, 11 Jul 2026 02:58:39 +0800 Subject: [PATCH 452/707] Refactor iZone config flow to on-demand discovery and handling multiple devices (#169251) Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Cursor --- homeassistant/components/izone/__init__.py | 95 +- homeassistant/components/izone/climate.py | 27 +- homeassistant/components/izone/config_flow.py | 395 +++- homeassistant/components/izone/const.py | 3 +- homeassistant/components/izone/discovery.py | 215 +- homeassistant/components/izone/strings.json | 16 +- tests/components/izone/conftest.py | 104 +- .../izone/snapshots/test_climate.ambr | 14 +- tests/components/izone/test_climate.py | 65 +- tests/components/izone/test_config_flow.py | 1942 ++++++++++++++++- 10 files changed, 2738 insertions(+), 138 deletions(-) diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 9f5e0d2e9c36..c3d17460e31d 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -4,13 +4,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_EXCLUDE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from . import discovery from .const import DATA_CONFIG, DOMAIN -from .discovery import async_start_discovery_service, async_stop_discovery_service PLATFORMS = [Platform.CLIMATE] @@ -30,35 +31,101 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" - - # Check for manually added config, this may exclude some devices if conf := config.get(DOMAIN): hass.data[DATA_CONFIG] = conf - # Explicitly added in the config file, create a config entry. hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) ) - # Start the discovery service - await async_start_discovery_service(hass) - - async def shutdown_event(event): - await async_stop_discovery_service(hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) - return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + try: + await discovery.async_start_discovery_service(hass) + except OSError as err: + raise ConfigEntryNotReady("iZone discovery service failed to start") from err + + if entry.unique_id == DOMAIN: + # Legacy v1-migrated entry: resolve to a real controller UID at setup time. + # + # Doing this work here (rather than in async_migrate_entry) is intentional: + # ConfigEntryNotReady raised from async_migrate_entry becomes a permanent + # MIGRATION_ERROR — HA does not retry failed migrations. Raising it from + # async_setup_entry correctly schedules a retry on the next HA start. + # + # Raising ConfigEntryError (multiple eligible controllers) is permanent either + # way; those controllers are not lost — the discovery fan-out will surface them + # as individual flows once HA restarts. This is not a breaking change: a v1 + # entry with multiple controllers was already broken before this PR. + # async_discover_controllers reuses the already-running service (idempotent + # start), so OSError here means fetch_controllers() itself failed — rare but + # kept as a defensive guard. + try: + controllers = await discovery.async_discover_controllers(hass) + except OSError as err: + raise ConfigEntryNotReady( + "iZone discovery failed while resolving legacy config entry" + ) from err + + conf: ConfigType | None = hass.data.get(DATA_CONFIG) + excluded_uids: set[str] = set(conf.get(CONF_EXCLUDE, [])) if conf else set() + configured_uids = { + config_entry.unique_id + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + and config_entry.unique_id not in (None, DOMAIN) + } + eligible = [ + controller + for controller in controllers.values() + if controller.device_uid not in excluded_uids + and controller.device_uid not in configured_uids + ] + + if not eligible: + raise ConfigEntryNotReady( + "No eligible iZone controller found to bind to legacy config entry" + ) + + if len(eligible) > 1: + raise ConfigEntryError( + "Multiple eligible iZone controllers found for a legacy config entry; " + "delete this entry and re-add each controller individually" + ) + + controller = eligible[0] + new_title = ( + f"iZone {controller.device_uid}" + if entry.title == "iZone Aircon" + else entry.title + ) + hass.config_entries.async_update_entry( + entry, + unique_id=controller.device_uid, + title=new_title, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entry schema to the current version.""" + if entry.version == 1: + # Clear legacy data only — UID and title binding is deferred to + # async_setup_entry where ConfigEntryNotReady retry semantics work correctly. + # Raising ConfigEntryNotReady from async_migrate_entry would permanently land + # the entry in MIGRATION_ERROR with no retry path. + hass.config_entries.async_update_entry(entry, version=2, data={}) + return True + return False + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload the config entry and stop discovery process.""" + """Unload the config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index a1cbf3078fc5..618e9036f287 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -22,7 +22,6 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_EXCLUDE, PRECISION_HALVES, PRECISION_TENTHS, UnitOfTemperature, @@ -33,10 +32,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.temperature import display_temp as show_temp -from homeassistant.helpers.typing import ConfigType, VolDictType +from homeassistant.helpers.typing import VolDictType from .const import ( - DATA_CONFIG, DATA_DISCOVERY_SERVICE, DISPATCH_CONTROLLER_DISCONNECTED, DISPATCH_CONTROLLER_DISCOVERED, @@ -44,6 +42,7 @@ from .const import ( DISPATCH_CONTROLLER_UPDATE, DISPATCH_ZONE_UPDATE, DOMAIN, + TIMEOUT_DISCOVERY, ) type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] @@ -77,25 +76,29 @@ async def async_setup_entry( ) -> None: """Initialize an IZone Controller.""" disco = hass.data[DATA_DISCOVERY_SERVICE] + entry_unique_id = config.unique_id + initialized = False @callback def init_controller(ctrl: Controller): """Register the controller device and the containing zones.""" - conf: ConfigType | None = hass.data.get(DATA_CONFIG) - - # Filter out any entities excluded in the config file - if conf and ctrl.device_uid in conf[CONF_EXCLUDE]: - _LOGGER.debug("Controller UID=%s ignored as excluded", ctrl.device_uid) + nonlocal initialized + if entry_unique_id and ctrl.device_uid != entry_unique_id: + return + if initialized: return - _LOGGER.debug("Controller UID=%s discovered", ctrl.device_uid) + initialized = True device = ControllerDevice(ctrl) async_add_entities([device]) async_add_entities(device.zones.values()) + _LOGGER.debug("Controller UID=%s initialized", ctrl.device_uid) - # create any components not yet created - for controller in (await disco.pi_disco.fetch_controllers()).values(): - init_controller(controller) + # Fetch the controller for this entry, waiting for discovery if it hasn't been found yet + if ctrl := await disco.pi_disco.fetch_controller( + entry_unique_id, timeout=TIMEOUT_DISCOVERY + ): + init_controller(ctrl) # connect to register any further components config.async_on_unload( diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 762949b7e184..8525473c34df 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -1,42 +1,387 @@ """Config flow for izone.""" -import asyncio -from contextlib import suppress +from collections.abc import Iterable import logging +from typing import Any, Self, override +import pizone +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_entry_flow -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.typing import DiscoveryInfoType -from .const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN, TIMEOUT_DISCOVERY -from .discovery import async_start_discovery_service, async_stop_discovery_service +from . import discovery as izone_discovery +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SELECTED_CONTROLLER_UID = "selected_controller_uid" -async def _async_has_devices(hass: HomeAssistant) -> bool: - controller_ready = asyncio.Event() + +def _flow_uid_for_matching(flow: ConfigFlow) -> str | None: + """Return a stable controller UID for deduplicating in-progress flows.""" + ctx_uid = flow.context.get("unique_id") + if isinstance(ctx_uid, str): + return ctx_uid + return None + + +class IZoneConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow: user, YAML import, HomeKit, and integration discovery.""" + + VERSION = 2 + + _user_discovered_controllers: list[pizone.Controller] | None = None + _discovered_controller_ip: str | None = None + + @override + def is_matching(self, other_flow: Self) -> bool: + """Match in-progress flows for the same controller UID.""" + self_uid = _flow_uid_for_matching(self) + other_uid = _flow_uid_for_matching(other_flow) + if self_uid is None or other_uid is None: + return False + return self_uid == other_uid + + # -- User-visible and internal steps (roughly: import → user → discovery UI → HK → fan-out → confirm) + + async def async_step_import( + self, _import_data: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """YAML import: start discovery and let runtime discovery offer flows. + + The import step runs exactly once (Home Assistant calls it only when the YAML + key is present at startup). We start the discovery service so every controller + surfaced by the service's normal listener appears in discovered devices and + still requires normal confirmation. + + No explicit rescan is issued: the service will broadcast its own discovery + request as part of start-up, and the import step itself will not be repeated. + """ + if self._async_in_progress(include_uninitialized=True): + return self.async_abort(reason="already_in_progress") + + try: + await izone_discovery.async_start_discovery_service(self.hass) + except OSError: + _LOGGER.debug("Unable to start iZone discovery from import", exc_info=True) + return self.async_abort(reason="discovery_failed") + + # Discovery is now running; each controller will surface as an individual + # integration_discovery flow. Use a dedicated abort reason so the UI does + # not misleadingly show "No devices found" when setup is actually in progress. + return self.async_abort(reason="discovery_started") + + @override + async def async_step_user( + self, _user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User-started flow: offer configuration choices for discovered controllers. + + Discovery is started if not yet running, then a fresh discovery cycle is triggered + and this step waits briefly for replies. The pizone library's built-in coalescing + avoids redundant broadcasts when discovery was just started. + + While this interactive flow is active, runtime integration discovery remains + blocked by ``_async_blocks_runtime_integration_discovery`` to avoid UI races. + """ + + if self._async_in_progress(include_uninitialized=True): + return self.async_abort(reason="already_in_progress") + + try: + controllers = await izone_discovery.async_discover_controllers( + self.hass, refresh=True + ) + except OSError: + _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) + return self.async_abort(reason="discovery_failed") + if not controllers: + _LOGGER.debug("No controllers found") + return self.async_abort(reason="no_devices_found") + + self._user_discovered_controllers = self._async_get_unconfigured_controllers( + controllers + ) + if not self._user_discovered_controllers: + return self.async_abort(reason="already_configured") + if len(self._user_discovered_controllers) > 1: + return await self.async_step_select_controller() + + sole = self._user_discovered_controllers[0] + await self.async_set_unique_id(sole.device_uid) + self._discovered_controller_ip = sole.device_ip + return await self.async_step_confirm() + + async def async_step_select_controller( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose one unconfigured controller after broadcast discovery.""" + if not self._user_discovered_controllers: + return self.async_abort(reason="no_devices_found") + + by_uid = { + controller.device_uid: controller + for controller in self._user_discovered_controllers + } + selection_schema = vol.Schema( + { + vol.Required( + SELECTED_CONTROLLER_UID, + default=self._user_discovered_controllers[0].device_uid, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=controller.device_uid, + label=( + f"{controller.device_uid} ({controller.device_ip})" + ), + ) + for controller in self._user_discovered_controllers + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) + + if user_input is not None: + selected_uid = user_input[SELECTED_CONTROLLER_UID] + if (primary := by_uid.get(selected_uid)) is None: + return self.async_abort(reason="no_devices_found") + + for ctrl in self._user_discovered_controllers: + if ctrl.device_uid == primary.device_uid: + continue + # Using integration_discovery lets HA's deduplication guard prevent stacking + # flows for UIDs already in progress or already configured. + self._async_schedule_integration_discovery_flow( + ctrl.device_uid, + ctrl.device_ip, + ) + return await self._async_create_controller_entry(primary) + + controllers_lines = "\n".join( + f"- {controller.device_uid} ({controller.device_ip})" + for controller in self._user_discovered_controllers + ) + return self.async_show_form( + step_id="select_controller", + data_schema=selection_schema, + description_placeholders={"controllers": controllers_lines}, + ) + + @override + async def async_step_homekit( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Map HomeKit ``md`` to an iZone UID, discover LAN controllers, then confirm.""" + model = discovery_info.properties.get("md", "") + if not model.startswith("iZone "): + return self.async_abort(reason="no_devices_found") + + device_uid = model.split(" ", 1)[1] + + if device_uid in izone_discovery.yaml_excluded_uids(self.hass): + return self.async_abort(reason="no_devices_found") + + # async_set_unique_id + _abort_if_unique_id_configured handles both existing + # entries (including SOURCE_IGNORE) and stale in-progress flows for this UID. + # A direct async_entry_for_domain_unique_id pre-check would miss the + # flow-deduplication side effect of async_set_unique_id(raise_on_progress=True). + await self.async_set_unique_id(device_uid) + self._abort_if_unique_id_configured() + + # A HomeKit advertisement implies a specific UID is on the LAN. Wait for it. + try: + controllers = await izone_discovery.async_discover_controllers( + self.hass, + refresh=True, + wait_for_uid=device_uid, + ) + except OSError: + _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) + return self.async_abort(reason="discovery_failed") + controller = controllers.get(device_uid) + if controller is None: + return self.async_abort(reason="no_devices_found") + + self._discovered_controller_ip = controller.device_ip + + # Re-check after awaiting discovery to catch mid-flight configuration. + self._abort_if_unique_id_configured() + + self._async_fan_out_discovered_controllers( + controllers.values(), + selected_uid=device_uid, + ) + + return await self.async_step_confirm() + + @override + async def async_step_integration_discovery( + self, discovery_info: DiscoveryInfoType + ) -> ConfigFlowResult: + """Handle fan-out, YAML import secondaries, and runtime discovery.""" + uid = self.context["unique_id"] + host = discovery_info[CONF_HOST] + if uid in izone_discovery.yaml_excluded_uids(self.hass): + return self.async_abort(reason="no_devices_found") + + await self.async_set_unique_id(uid) + self._abort_if_unique_id_configured() + # Discovery host is for confirm-step context only; runtime discovery owns + # current device IP state and keeps it up to date independently of entry data. + self._discovered_controller_ip = host + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm adding a controller found via HomeKit or manual host.""" + if user_input is None: + controller_uid = self.unique_id + host = self._discovered_controller_ip + assert isinstance(controller_uid, str) + assert controller_uid + assert host is not None + host_str = str(host) + self.context["title_placeholders"] = { + "name": self._entry_title(controller_uid), + } + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "controller_uid": controller_uid, + "host": host_str, + }, + ) + + try: + controllers = await izone_discovery.async_discover_controllers(self.hass) + except OSError: + _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) + return self.async_abort(reason="discovery_failed") + if not controllers: + _LOGGER.debug("No controllers found") + return self.async_abort(reason="no_devices_found") + + uid = self.unique_id + assert isinstance(uid, str) + + controller = controllers.get(uid) + if controller is None: + _LOGGER.debug( + "Discovered controller UID %s was not found during confirmation", + uid, + ) + return self.async_abort(reason="no_devices_found") + return await self._async_create_controller_entry( + controller, + ) + + # -- Private helpers @callback - def dispatch_discovered(_): - controller_ready.set() + def _async_schedule_integration_discovery_flow( + self, + uid: str, + host: str, + ) -> None: + """Queue integration discovery (import fan-out or manual discovery pick).""" + discovery_flow.async_create_flow( + self.hass, + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": uid, + }, + data={CONF_HOST: host}, + ) - async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, dispatch_discovered) + @staticmethod + def _entry_title(device_uid: str) -> str: + """Standard config entry title for a controller UID.""" + return f"iZone {device_uid}" - disco = await async_start_discovery_service(hass) + @staticmethod + def _filter_yaml_exclude( + hass: HomeAssistant, controllers: dict[str, pizone.Controller] + ) -> dict[str, pizone.Controller]: + """Remove UIDs listed in deprecated YAML ``exclude``.""" + excluded = izone_discovery.yaml_excluded_uids(hass) + if not excluded: + return controllers + return { + uid: ctrl + for uid, ctrl in controllers.items() + if ctrl.device_uid not in excluded + } - with suppress(TimeoutError): - async with asyncio.timeout(TIMEOUT_DISCOVERY): - await controller_ready.wait() + @callback + def _async_get_unconfigured_controllers( + self, controllers: dict[str, pizone.Controller] + ) -> list[pizone.Controller]: + """Return sorted unconfigured controllers for the interactive user flow.""" + controllers = self._filter_yaml_exclude(self.hass, controllers) + # include_ignore=True ensures controllers whose entries have been explicitly + # ignored by the user (SOURCE_IGNORE) are not re-offered as configurable. + configured_uids = self._async_current_ids(include_ignore=True) + return sorted( + ( + controller + for controller in controllers.values() + if controller.device_uid not in configured_uids + ), + key=lambda controller: (controller.device_uid, controller.device_ip), + ) - controllers = await disco.pi_disco.fetch_controllers() - if not controllers: - await async_stop_discovery_service(hass) - _LOGGER.debug("No controllers found") - return False + async def _async_create_controller_entry( + self, + controller: pizone.Controller, + ) -> ConfigFlowResult: + """Create the config entry for a chosen :class:`pizone.Controller` instance.""" + await self.async_set_unique_id(controller.device_uid) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._entry_title(controller.device_uid), + data={}, + ) - _LOGGER.debug("Controllers %s", controllers) - return True - - -config_entry_flow.register_discovery_flow(DOMAIN, "iZone Aircon", _async_has_devices) + @callback + def _async_fan_out_discovered_controllers( + self, + controllers: Iterable[pizone.Controller], + *, + selected_uid: str, + ) -> None: + """Start confirm flows for every other discovered UID (import uses its own path).""" + current_ids = self._async_current_ids(include_ignore=True) + in_progress_ids = { + flow["context"].get("unique_id") + for flow in self._async_in_progress(include_uninitialized=True) + } + for candidate in controllers: + if candidate.device_uid == selected_uid: + continue + if ( + candidate.device_uid in current_ids + or candidate.device_uid in in_progress_ids + ): + continue + self._async_schedule_integration_discovery_flow( + candidate.device_uid, + candidate.device_ip, + ) diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py index 99d75bf92b54..eb8f76ed1daa 100644 --- a/homeassistant/components/izone/const.py +++ b/homeassistant/components/izone/const.py @@ -11,4 +11,5 @@ DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_reconnected" DISPATCH_CONTROLLER_UPDATE = "izone_controller_update" DISPATCH_ZONE_UPDATE = "izone_zone_update" -TIMEOUT_DISCOVERY = 20 +TIMEOUT_DISCOVERY = 5 +DISCOVERY_IDLE_SECONDS = 4 * TIMEOUT_DISCOVERY diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index 81537bbdbbcf..6103e2e16254 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -1,25 +1,107 @@ -"""Internal discovery service for iZone AC.""" +"""Internal discovery service for iZone AC.""" +import asyncio +from collections.abc import Callable import logging import pizone -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant import config_entries +from homeassistant.const import CONF_EXCLUDE, CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import aiohttp_client, discovery_flow +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.typing import ConfigType from .const import ( + DATA_CONFIG, DATA_DISCOVERY_SERVICE, + DISCOVERY_IDLE_SECONDS, DISPATCH_CONTROLLER_DISCONNECTED, DISPATCH_CONTROLLER_DISCOVERED, DISPATCH_CONTROLLER_RECONNECTED, DISPATCH_CONTROLLER_UPDATE, DISPATCH_ZONE_UPDATE, + DOMAIN, + TIMEOUT_DISCOVERY, ) _LOGGER = logging.getLogger(__name__) +async def async_discover_controllers( + hass: HomeAssistant, + *, + refresh: bool = False, + wait_for_uid: str | None = None, +) -> dict[str, pizone.Controller]: + """Return currently known controllers, optionally waiting for a UID during rescan. + + If ``refresh`` is true, waits for fresh discovery data using the pizone library's + built-in coalescing and cool-down logic. When ``wait_for_uid`` is provided, returns + as soon as that specific controller appears (or after the timeout). + + If discovery is not yet running, it is started first. + + Raises: + OSError: Discovery service failed to start or controller fetch failed. + """ + disco = await async_start_discovery_service(hass) + assert disco.pi_disco is not None + + if not refresh: + return await disco.pi_disco.fetch_controllers() + + if wait_for_uid is not None: + await disco.pi_disco.fetch_controller(wait_for_uid, timeout=TIMEOUT_DISCOVERY) + return await disco.pi_disco.fetch_controllers() + + return await disco.pi_disco.fetch_controllers(timeout=TIMEOUT_DISCOVERY) + + +def yaml_excluded_uids(hass: HomeAssistant) -> set[str]: + """Return controller UIDs listed in deprecated YAML ``exclude``.""" + conf: ConfigType | None = hass.data.get(DATA_CONFIG) + if not conf: + return set() + return set(conf.get(CONF_EXCLUDE, ())) + + +@callback +def async_note_integration_discovery( + hass: HomeAssistant, ctrl: pizone.Controller +) -> None: + """Start a config flow when the shared discovery service reports a controller.""" + if ctrl.device_uid in yaml_excluded_uids(hass): + return + if _async_blocks_runtime_integration_discovery(hass): + return + discovery_flow.async_create_flow( + hass, + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": ctrl.device_uid, + }, + data={CONF_HOST: ctrl.device_ip}, + ) + + +@callback +def _async_blocks_runtime_integration_discovery(hass: HomeAssistant) -> bool: + """Return True when an interactive setup flow should own the UI.""" + for flw in hass.config_entries.flow.async_progress_by_handler( + DOMAIN, include_uninitialized=True + ): + src = flw["context"].get("source") + if src == config_entries.SOURCE_USER: + return True + return False + + class DiscoveryService(pizone.Listener): """Discovery data and interfacing with pizone library.""" @@ -28,10 +110,34 @@ class DiscoveryService(pizone.Listener): super().__init__() self.hass = hass self.pi_disco: pizone.DiscoveryService | None = None + self.remove_stop_listener: Callable[[], None] | None = None + self.remove_config_flow_listener: Callable[[], None] | None = None + self._idle_stop_handle: asyncio.TimerHandle | None = None + + @callback + def async_schedule_idle_stop(self) -> None: + """Schedule a delayed shutdown check for discovery service.""" + if self._idle_stop_handle is not None: + self._idle_stop_handle.cancel() + + self._idle_stop_handle = self.hass.loop.call_later( + DISCOVERY_IDLE_SECONDS, + lambda: self.hass.async_create_task( + async_maybe_stop_discovery_service(self.hass) + ), + ) + + @callback + def async_cancel_idle_stop(self) -> None: + """Cancel any pending idle-stop timer.""" + if self._idle_stop_handle is not None: + self._idle_stop_handle.cancel() + self._idle_stop_handle = None # Listener interface def controller_discovered(self, ctrl: pizone.Controller) -> None: """Handle new controller discovery.""" + self.async_schedule_idle_stop() async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCOVERED, ctrl) def controller_disconnected(self, ctrl: pizone.Controller, ex: Exception) -> None: @@ -51,7 +157,7 @@ class DiscoveryService(pizone.Listener): async_dispatcher_send(self.hass, DISPATCH_ZONE_UPDATE, ctrl, zone) -async def async_start_discovery_service(hass: HomeAssistant): +async def async_start_discovery_service(hass: HomeAssistant) -> DiscoveryService: """Set up the pizone internal discovery.""" if disco := hass.data.get(DATA_DISCOVERY_SERVICE): # Already started @@ -60,21 +166,116 @@ async def async_start_discovery_service(hass: HomeAssistant): # discovery local services disco = DiscoveryService(hass) - hass.data[DATA_DISCOVERY_SERVICE] = disco # Start the pizone discovery service, disco is the listener session = aiohttp_client.async_get_clientsession(hass) disco.pi_disco = pizone.discovery(disco, session=session) + + @callback + def _async_on_controller_discovered(ctrl: pizone.Controller) -> None: + async_note_integration_discovery(hass, ctrl) + + disco.remove_config_flow_listener = async_dispatcher_connect( + hass, DISPATCH_CONTROLLER_DISCOVERED, _async_on_controller_discovered + ) + await disco.pi_disco.start_discovery() + # Stored after start_discovery() so concurrent callers never receive a + # partially-initialised DiscoveryService (no active UDP transport or scan loop). + hass.data[DATA_DISCOVERY_SERVICE] = disco + + async def async_stop_discovery_on_shutdown(event: Event) -> None: + """Stop discovery on Home Assistant shutdown.""" + # async_listen_once removes its own listener before running this callback. + # Clear our handle so async_stop_discovery_service does not try to remove it + # a second time, which logs an "unknown job listener" error. + disco.remove_stop_listener = None + await async_stop_discovery_service(hass) + + disco.remove_stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_stop_discovery_on_shutdown + ) + disco.async_schedule_idle_stop() return disco -async def async_stop_discovery_service(hass: HomeAssistant): +@callback +def _async_is_ignored_or_excluded_uid(hass: HomeAssistant, uid: str) -> bool: + """Return True when UID is excluded by YAML or ignored/disabled by config entries.""" + if uid in yaml_excluded_uids(hass): + return True + + return any( + entry.unique_id == uid + and ( + entry.source == config_entries.SOURCE_IGNORE + or entry.disabled_by is not None + ) + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +@callback +def _async_has_actionable_entries(hass: HomeAssistant) -> bool: + """Return True when there is at least one enabled, non-ignored iZone entry.""" + return any( + hass.config_entries.async_entries( + DOMAIN, include_ignore=False, include_disabled=False + ) + ) + + +@callback +def _async_has_actionable_flows(hass: HomeAssistant) -> bool: + """Return True when there is an in-progress iZone flow that can create/update state.""" + return any( + flow["context"].get("source") != config_entries.SOURCE_IGNORE + for flow in hass.config_entries.flow.async_progress_by_handler( + DOMAIN, include_uninitialized=True + ) + ) + + +async def async_maybe_stop_discovery_service(hass: HomeAssistant) -> None: + """Stop discovery after idle delay when no actionable controllers remain.""" + if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)): + return + + if _async_has_actionable_flows(hass) or _async_has_actionable_entries(hass): + disco.async_schedule_idle_stop() + return + + controllers_map = await disco.pi_disco.fetch_controllers() + if not controllers_map: + await async_stop_discovery_service(hass) + return + + if all( + _async_is_ignored_or_excluded_uid(hass, c.device_uid) + for c in controllers_map.values() + ): + await async_stop_discovery_service(hass) + return + + disco.async_schedule_idle_stop() + + +async def async_stop_discovery_service(hass: HomeAssistant) -> None: """Stop the discovery service.""" if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)): return + if disco.remove_stop_listener is not None: + disco.remove_stop_listener() + disco.remove_stop_listener = None + + if disco.remove_config_flow_listener is not None: + disco.remove_config_flow_listener() + disco.remove_config_flow_listener = None + + disco.async_cancel_idle_stop() + await disco.pi_disco.close() del hass.data[DATA_DISCOVERY_SERVICE] diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json index 0716bee80747..42b91a22a357 100644 --- a/homeassistant/components/izone/strings.json +++ b/homeassistant/components/izone/strings.json @@ -1,12 +1,22 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "discovery_failed": "Failed to start iZone discovery. Make sure your network is properly configured.", + "discovery_started": "iZone discovery has started. Your controllers will appear as discovered devices under Settings \u003e Devices \u0026 services.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, + "flow_title": "{name}", "step": { "confirm": { - "description": "Do you want to set up iZone?" + "description": "Do you want to set up iZone?\n\nController UID: {controller_uid}\nController IP: {host}" + }, + "select_controller": { + "data": { + "selected_controller_uid": "Controller" + }, + "description": "Multiple unconfigured iZone controllers were found:\n{controllers}\n\nChoose the controller you want to set up now. Any controller you do not select will remain available as a discovered device you can set up later under **Settings** > **Devices & services**." } } }, diff --git a/tests/components/izone/conftest.py b/tests/components/izone/conftest.py index f4eb2e0a5791..56340c887cc3 100644 --- a/tests/components/izone/conftest.py +++ b/tests/components/izone/conftest.py @@ -1,12 +1,17 @@ """Fixtures for iZone integration tests.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator, Iterable +from contextlib import contextmanager from unittest.mock import AsyncMock, Mock, patch from pizone import Controller, Zone import pytest -from homeassistant.components.izone.const import DOMAIN +from homeassistant.components.izone import discovery as izone_discovery +from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN +from homeassistant.const import CONF_EXCLUDE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -19,6 +24,8 @@ def mock_config_entry() -> MockConfigEntry: title="iZone", data={}, entry_id="test_entry_id", + unique_id="000000001", + version=2, ) @@ -33,7 +40,8 @@ def mock_pizone_discovery_service() -> Mock: def create_mock_controller( - device_uid: str = "test_controller_123", + device_uid: str = "000000001", + device_ip: str = "192.0.2.1", sys_type: str = "iZone310", zones_total: int = 4, zone_ctrl: int = 1, @@ -43,6 +51,7 @@ def create_mock_controller( """Create a mock Controller with configurable parameters.""" controller = Mock(spec=Controller) controller.device_uid = device_uid + controller.device_ip = device_ip controller.sys_type = sys_type controller.zones_total = zones_total controller.zone_ctrl = zone_ctrl @@ -86,6 +95,87 @@ def create_mock_zone( return zone +def create_mock_discovery_service(*controllers: Mock) -> Mock: + """Create a mock discovery service with the given controllers.""" + service = Mock() + service.pi_disco = Mock() + service.pi_disco.controllers = {c.device_uid: c for c in controllers} + service.pi_disco.fetch_controller = AsyncMock( + side_effect=lambda uid, timeout=None: service.pi_disco.controllers.get(uid) + ) + service.pi_disco.fetch_controllers = AsyncMock( + side_effect=lambda timeout=None: dict(service.pi_disco.controllers) + ) + service.async_schedule_idle_stop = Mock() + return service + + +@contextmanager +def patch_discovered_controllers( + controllers: Mock | dict[str, Mock] | Iterable[Mock], +) -> Generator[Mock]: + """Patch discovery startup so async_discover_controllers uses these controllers.""" + if isinstance(controllers, dict): + ctrl_list = list(controllers.values()) + elif isinstance(controllers, Mock): + ctrl_list = [controllers] + else: + ctrl_list = list(controllers) + service = create_mock_discovery_service(*ctrl_list) + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new_callable=AsyncMock, + return_value=service, + ): + yield service + + +async def async_load_yaml_exclude(hass: HomeAssistant, *uids: str) -> None: + """Load deprecated YAML exclude config through the integration setup path.""" + with ( + patch.object(hass, "async_create_task"), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=Mock(start_discovery=AsyncMock(), close=AsyncMock()), + ), + ): + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_EXCLUDE: list(uids)}} + ) + + +async def async_install_discovery_service( + hass: HomeAssistant, *controllers: Mock +) -> Mock: + """Start the discovery service with mocked pizone and optional controllers.""" + mock_pi_disco = create_mock_discovery_service(*controllers).pi_disco + mock_pi_disco.start_discovery = AsyncMock() + mock_pi_disco.close = AsyncMock() + with ( + patch( + "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", + return_value=Mock(), + ), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=mock_pi_disco, + ), + ): + service = await izone_discovery.async_start_discovery_service(hass) + assert DATA_DISCOVERY_SERVICE in hass.data + return service + + +@pytest.fixture +def mock_entry_setup() -> Generator[None]: + """Patch climate platform setup for entry-creating config flow tests.""" + with patch( + "homeassistant.components.izone.climate.async_setup_entry", + return_value=True, + ): + yield + + @pytest.fixture async def mock_discovery( mock_controller: AsyncMock, mock_zones: list[AsyncMock] @@ -96,10 +186,13 @@ async def mock_discovery( "homeassistant.components.izone.discovery.pizone.discovery", autospec=True ) as mock_disco: mock_disco.return_value.start_discovery = AsyncMock() + mock_disco.return_value.close = AsyncMock() + mock_disco.return_value.fetch_controller = AsyncMock( + return_value=mock_controller + ) mock_disco.return_value.fetch_controllers = AsyncMock( return_value={mock_controller.device_uid: mock_controller} ) - mock_disco.return_value.close = AsyncMock() yield mock_disco @@ -113,7 +206,8 @@ async def mock_zones() -> list[AsyncMock]: async def mock_controller(mock_zones: list[AsyncMock]) -> AsyncMock: """Create a mock controller.""" return create_mock_controller( - device_uid="test_controller_123", + device_uid="000000001", + device_ip="192.0.2.1", ras_mode="master", zone_ctrl=1, zones_total=1, diff --git a/tests/components/izone/snapshots/test_climate.ambr b/tests/components/izone/snapshots/test_climate.ambr index 5d320e93fb64..751093d19689 100644 --- a/tests/components/izone/snapshots/test_climate.ambr +++ b/tests/components/izone/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_basic_controller_properties[climate.izone_controller_test_controller_123-entry] +# name: test_basic_controller_properties[climate.izone_controller_000000001-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -31,7 +31,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.izone_controller_test_controller_123', + 'entity_id': 'climate.izone_controller_000000001', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -50,11 +50,11 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'test_controller_123', + 'unique_id': '000000001', 'unit_of_measurement': None, }) # --- -# name: test_basic_controller_properties[climate.izone_controller_test_controller_123-state] +# name: test_basic_controller_properties[climate.izone_controller_000000001-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'control_zone': 1, @@ -68,7 +68,7 @@ 'high', 'auto', ]), - : 'iZone Controller test_controller_123', + : 'iZone Controller 000000001', : list([ , , @@ -85,7 +85,7 @@ 'temp_setpoint': 24.0, }), 'context': , - 'entity_id': 'climate.izone_controller_test_controller_123', + 'entity_id': 'climate.izone_controller_000000001', 'last_changed': , 'last_reported': , 'last_updated': , @@ -134,7 +134,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'test_controller_123_z1', + 'unique_id': '000000001_z1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/izone/test_climate.py b/tests/components/izone/test_climate.py index bed507ff89b7..a0a7a922bce8 100644 --- a/tests/components/izone/test_climate.py +++ b/tests/components/izone/test_climate.py @@ -1,6 +1,6 @@ """Tests for iZone climate platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -29,7 +29,7 @@ async def test_basic_controller_properties( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -57,7 +57,7 @@ async def test_target_temperature_feature_ras_mode( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -86,7 +86,7 @@ async def test_target_temperature_feature_master_mode_invalid_zone( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -123,7 +123,7 @@ async def test_target_temperature_feature_zone_without_sensor( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -158,7 +158,7 @@ async def test_target_temperature_feature_all_zones_with_sensors( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -194,7 +194,7 @@ async def test_target_temperature_feature_multiple_zones_one_without_sensor( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -222,7 +222,7 @@ async def test_target_temperature_feature_slave_mode( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -252,7 +252,7 @@ async def test_target_temperature_feature_master_mode_zone_13( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -260,3 +260,50 @@ async def test_target_temperature_feature_master_mode_zone_13( entity.attributes["supported_features"] & ClimateEntityFeature.TARGET_TEMPERATURE ) == ClimateEntityFeature.TARGET_TEMPERATURE + + +async def test_setup_entry_only_adds_entities_for_matching_config_entry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test a config entry only adds entities for its matching controller.""" + matching_controller = create_mock_controller( + device_uid="000000001", device_ip="192.0.2.1", zones_total=1 + ) + matching_controller.zones = [create_mock_zone(index=0, name="Living Room")] + + other_controller = create_mock_controller( + device_uid="000000002", device_ip="192.0.2.2", zones_total=1 + ) + other_controller.zones = [create_mock_zone(index=0, name="Bedroom")] + + entry = MockConfigEntry( + domain="izone", + title="iZone", + data={}, + unique_id="000000001", + entry_id="test_entry_id", + version=2, + ) + + with patch( + "homeassistant.components.izone.discovery.pizone.discovery", autospec=True + ) as mock_disco: + mock_disco.return_value.start_discovery = AsyncMock() + mock_disco.return_value.close = AsyncMock() + mock_disco.return_value.fetch_controller = AsyncMock( + return_value=matching_controller + ) + mock_disco.return_value.fetch_controllers = AsyncMock( + return_value={ + matching_controller.device_uid: matching_controller, + other_controller.device_uid: other_controller, + } + ) + + await setup_integration(hass, entry) + + entry_entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + unique_ids = {entity.unique_id for entity in entry_entities} + + assert unique_ids == {"000000001", "000000001_z1"} diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 6465e7a18c43..e58a0c30e6f9 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -1,91 +1,1923 @@ -"""Tests for iZone.""" +"""Tests for iZone config flow.""" -from collections.abc import Callable -from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator +from types import SimpleNamespace +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components.izone.const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN +from homeassistant.components.izone import config_flow, discovery as izone_discovery +from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from .conftest import ( + async_install_discovery_service, + async_load_yaml_exclude, + create_mock_controller, + create_mock_discovery_service, + patch_discovered_controllers, +) + +from tests.common import MockConfigEntry -@pytest.fixture -def mock_disco() -> Mock: - """Mock discovery service.""" - disco = Mock() - disco.pi_disco = Mock() - disco.pi_disco.fetch_controllers = AsyncMock(return_value={}) - return disco +def _make_homekit_info(md: str, host: str | None = None) -> SimpleNamespace: + """Return a minimal HomeKit discovery info object with attributes.""" + return SimpleNamespace(properties={"md": md}, host=host) -def _mock_start_discovery(hass: HomeAssistant, mock_disco: Mock) -> Callable[..., Mock]: - def do_disovered(*args: Any) -> Mock: - async_dispatcher_send(hass, DISPATCH_CONTROLLER_DISCOVERED, True) - return mock_disco - - return do_disovered - - -async def test_not_found(hass: HomeAssistant, mock_disco: Mock) -> None: - """Test not finding iZone controller.""" - +@pytest.fixture(autouse=True) +def mock_izone_timeouts() -> Generator[None]: + """Mock iZone timeout constants to speed up tests.""" with ( patch( - "homeassistant.components.izone.config_flow.async_start_discovery_service" - ) as start_disco, + "homeassistant.components.izone.discovery.TIMEOUT_DISCOVERY", + 0.01, + ), patch( - "homeassistant.components.izone.config_flow.async_stop_discovery_service", - return_value=None, - ) as stop_disco, + "homeassistant.components.izone.discovery.DISCOVERY_IDLE_SECONDS", + 0.04, + ), ): - start_disco.side_effect = _mock_start_discovery(hass, mock_disco) + yield + + +async def test_user_discovery_success( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test user flow confirms and creates an entry for a discovered controller.""" + controller = create_mock_controller("000000001", "192.0.2.55") + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" + + +async def test_user_discovery_default_selects_first_and_queues_other( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Default dropdown selection configures first UID and queues the other for confirm.""" + first = create_mock_controller("000000001", "192.0.2.1") + second = create_mock_controller("000000002", "192.0.2.2") + with patch_discovered_controllers([first, second]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_controller" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + progress = [ + p + for p in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if p["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ] + assert len(progress) == 1 + assert progress[0]["step_id"] == "confirm" + assert progress[0]["context"]["unique_id"] == "000000002" + + +async def test_broadcast_skips_already_configured_controller( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test broadcast discovery skips configured controllers and sets up an unconfigured one.""" + configured_controller = create_mock_controller("000000001", "192.0.2.1") + unconfigured_controller = create_mock_controller("000000002", "192.0.2.2") + MockConfigEntry( + domain=DOMAIN, + unique_id=configured_controller.device_uid, + data={}, + version=2, + ).add_to_hass(hass) + + with patch_discovered_controllers([configured_controller, unconfigured_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["data"] == {} + assert result["result"].unique_id == "000000002" + + +async def test_user_discovery_skips_yaml_excluded_controllers( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """User flow should not offer controllers excluded by deprecated YAML config.""" + excluded_controller = create_mock_controller("000000001", "192.0.2.1") + allowed_controller = create_mock_controller("000000002", "192.0.2.2") + await async_load_yaml_exclude(hass, excluded_controller.device_uid) + + with patch_discovered_controllers([excluded_controller, allowed_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["data"] == {} + assert result["result"].unique_id == "000000002" + + +async def test_broadcast_multiple_unconfigured_shows_choice( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test broadcast discovery shows a controller choice when multiple unconfigured controllers are found.""" + first_controller = create_mock_controller("000000002", "192.0.2.1") + second_controller = create_mock_controller("000000001", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - # Confirmation form assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_controller" + schema_keys = list(result["data_schema"].schema.keys()) + assert len(schema_keys) == 1 + assert str(schema_keys[0].schema) == config_flow.SELECTED_CONTROLLER_UID + + # Choose one and queue the other as integration discovery (confirm step). + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].unique_id == "000000001" + + progress = [ + p + for p in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if p["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ] + assert len(progress) == 1 + assert progress[0]["step_id"] == "confirm" + assert progress[0]["context"]["unique_id"] == "000000002" + + +async def test_select_controller_aborts_when_choices_missing( + hass: HomeAssistant, +) -> None: + """Test controller selection aborts if the discovered controller choices are missing.""" + first_controller = create_mock_controller("000000001", "192.0.2.1") + second_controller = create_mock_controller("000000002", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow._user_discovered_controllers = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_select_controller_aborts_when_uid_not_in_choices( + hass: HomeAssistant, +) -> None: + """Test controller selection aborts if a submitted UID is not in the choices.""" + first_controller = create_mock_controller("000000001", "192.0.2.1") + second_controller = create_mock_controller("000000002", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + result = await flow.async_step_select_controller( + {config_flow.SELECTED_CONTROLLER_UID: "000000099"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_select_controller_creates_selected_uid_and_queues_others( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """A selected controller is configured and non-selected controllers are queued.""" + first_controller = create_mock_controller("000000002", "192.0.2.1") + second_controller = create_mock_controller("000000001", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {config_flow.SELECTED_CONTROLLER_UID: "000000002"}, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["result"].unique_id == "000000002" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + skipped_flows = [ + p + for p in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if p["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ] + assert len(skipped_flows) == 1 + assert skipped_flows[0]["step_id"] == "confirm" + assert skipped_flows[0]["context"]["unique_id"] == "000000001" + + +async def test_broadcast_aborts_when_all_discovered_are_configured( + hass: HomeAssistant, +) -> None: + """Test broadcast discovery aborts when every discovered controller is configured.""" + configured_controller = create_mock_controller("000000001", "192.0.2.1") + MockConfigEntry( + domain=DOMAIN, + unique_id=configured_controller.device_uid, + data={}, + version=2, + ).add_to_hass(hass) + + with patch_discovered_controllers(configured_controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_aborts_when_all_discovered_are_ignored( + hass: HomeAssistant, +) -> None: + """User flow aborts when every discovered controller has been explicitly ignored. + + _async_get_unconfigured_controllers uses include_ignore=True so controllers + whose entries carry SOURCE_IGNORE are not re-offered as configurable, respecting + the user's earlier choice to dismiss them. + """ + ignored_controller = create_mock_controller("000000001", "192.0.2.1") + MockConfigEntry( + domain=DOMAIN, + unique_id=ignored_controller.device_uid, + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + + with patch_discovered_controllers(ignored_controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reuses_existing_discovery_service( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test config flow reuses the running discovery service without starting a new one.""" + controller = create_mock_controller("000000002", "192.0.2.2") + await async_install_discovery_service(hass, controller) + + with patch( + "homeassistant.components.izone.config_flow.pizone.discovery", + ) as mock_pizone_discovery: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["data"] == {} + assert result["result"].unique_id == "000000002" + mock_pizone_discovery.assert_not_called() + + +async def test_import_starts_discovery_service( + hass: HomeAssistant, +) -> None: + """Test YAML import starts discovery so runtime discovery can offer flows.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new=AsyncMock(), + ) as mock_start: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_started" + mock_start.assert_awaited_once_with(hass) + + +async def test_import_logs_and_aborts_when_discovery_service_cannot_start( + hass: HomeAssistant, +) -> None: + """Test YAML import aborts cleanly if discovery startup raises OSError.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new=AsyncMock(side_effect=OSError), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +async def test_import_aborts_when_another_izone_flow_in_progress( + hass: HomeAssistant, +) -> None: + """Test YAML import does not overlap discovery with an active user flow.""" + controller = create_mock_controller("000000001", "192.0.2.1") + with patch_discovered_controllers(controller): + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert user_flow["type"] is FlowResultType.FORM + assert user_flow["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_homekit_confirm_uses_discovered_host( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test HomeKit flow confirms and uses the discovered controller IP, not the HomeKit host.""" + controller = create_mock_controller(device_ip="192.0.2.3") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert flow["context"]["title_placeholders"] == {"name": "iZone 000000001"} + assert result["description_placeholders"] == { + "controller_uid": "000000001", + "host": "192.0.2.3", + } result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT await hass.async_block_till_done() - stop_disco.assert_called_once() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" -async def test_found(hass: HomeAssistant, mock_disco: Mock) -> None: - """Test not finding iZone controller.""" - mock_disco.pi_disco.fetch_controllers = AsyncMock(return_value={"blah": object()}) +async def test_homekit_fans_out_other_discovered_controllers( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow fans out additional discovered controllers.""" + matched_controller = create_mock_controller("000000001", "192.0.2.3") + other_controller = create_mock_controller("000000002", "192.0.2.4") + + with patch_discovered_controllers([matched_controller, other_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + await hass.async_block_till_done() + + progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(progress) == 2 + + current_flow = next( + flow for flow in progress if flow["flow_id"] == result["flow_id"] + ) + assert current_flow["context"]["source"] == config_entries.SOURCE_HOMEKIT + + fanout_flow = next( + flow for flow in progress if flow["flow_id"] != result["flow_id"] + ) + assert fanout_flow["step_id"] == "confirm" + assert ( + fanout_flow["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ) + assert fanout_flow["context"]["unique_id"] == "000000002" + + +async def test_homekit_flow_sets_device_uid_once( + hass: HomeAssistant, +) -> None: + """HomeKit flow sets unique_id to the device UID exactly once (no lock-ID swap).""" + controller = create_mock_controller("000000001", "192.0.2.3") + set_unique_id_calls: list[str] = [] + original_set_unique_id = config_flow.IZoneConfigFlow.async_set_unique_id + + async def _recording_set_unique_id( + self: config_flow.IZoneConfigFlow, uid: str + ) -> None: + set_unique_id_calls.append(uid) + await original_set_unique_id(self, uid) with ( + patch_discovered_controllers(controller), + patch.object( + config_flow.IZoneConfigFlow, + "async_set_unique_id", + _recording_set_unique_id, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert set_unique_id_calls == ["000000001"] + + +async def test_homekit_aborts_while_user_confirm_is_open( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """HomeKit onboarding for same UID is blocked while a user flow is already active.""" + controller = create_mock_controller("000000001", "192.0.2.3") + with patch_discovered_controllers(controller): + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert user_flow["type"] is FlowResultType.FORM + assert user_flow["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_user_broadcast_aborts_when_homekit_flow_in_progress( + hass: HomeAssistant, +) -> None: + """Test user broadcast discovery aborts when a HomeKit flow is already active.""" + controller = create_mock_controller("000000001", "192.0.2.3") + with patch_discovered_controllers(controller): + homekit_flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert homekit_flow["type"] is FlowResultType.FORM + assert homekit_flow["step_id"] == "confirm" + + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = user_flow + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_homekit_aborts_when_uid_already_configured( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts immediately when the discovered UID is already configured.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers", + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_discover_controllers.assert_not_called() + + +async def test_homekit_aborts_when_uid_configured_during_discovery( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts if the discovered UID gets configured mid-resolution.""" + controller = create_mock_controller("000000001", "192.0.2.3") + + async def _fetch_with_midflight_config(timeout=None): + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + return {controller.device_uid: controller} + + with patch_discovered_controllers(controller) as service: + service.pi_disco.fetch_controllers = AsyncMock( + side_effect=_fetch_with_midflight_config + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_homekit_aborts_when_uid_not_found_in_discovery( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts when the discovered UID cannot be found via iZone discovery.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_controller_unavailable_during_discovery_wait( + hass: HomeAssistant, +) -> None: + """HomeKit aborts when the advertised UID is not found during iZone discovery.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_flow_aborts_when_no_controllers_found(hass: HomeAssistant) -> None: + """User flow aborts when broadcast discovery returns no controllers.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + await hass.async_block_till_done() + + +async def test_user_flow_abort_when_discovery_service_cannot_start( + hass: HomeAssistant, +) -> None: + """User flow aborts when discovery startup fails.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +async def test_user_discovery_with_shared_service_without_matches_aborts( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """User flow aborts when discovery refresh returns no controllers.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_without_model_aborts( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow with a non-iZone model string aborts immediately.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("Other Device", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_without_model_does_not_start_discovery( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow does not trigger iZone discovery for a non-iZone model.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers", + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("Other Device"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + mock_discover_controllers.assert_not_called() + + +async def test_homekit_aborts_when_matching_uid_not_discovered( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts when no discovered controller matches model UID.""" + controller = create_mock_controller("000000003", "192.0.2.33") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_nothing_found(hass: HomeAssistant) -> None: + """Test HomeKit aborts when iZone discovery finds no controllers.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_discovered_uid_missing( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts when discovery returns controllers but not the advertised UID.""" + different_controller = create_mock_controller("000000002", "192.0.2.44") + + with patch_discovered_controllers(different_controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_flow_aborts_at_confirm_when_controller_disappears( + hass: HomeAssistant, +) -> None: + """HomeKit confirm aborts when the controller is gone by the time the user confirms.""" + controller = create_mock_controller("000000001", "192.0.2.3") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_discovery_startup_fails( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow aborts when discovery service cannot start.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +async def test_integration_discovery_aborts_for_yaml_excluded_uid( + hass: HomeAssistant, +) -> None: + """Integration discovery should abort for UIDs excluded in YAML config.""" + await async_load_yaml_exclude(hass, "000000002") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": "000000002", + }, + data={CONF_HOST: "192.0.2.2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_integration_discovery_aborts_for_ignored_uid( + hass: HomeAssistant, +) -> None: + """Integration discovery should abort for UIDs that have been ignored.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000002", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": "000000002", + }, + data={"host": "192.0.2.2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_runtime_integration_discovery_starts_confirm_flow( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """When the discovery service sees an unconfigured UID, offer setup.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + new_ctrl = create_mock_controller("000000002", "192.0.2.2") + + izone_discovery.async_note_integration_discovery(hass, new_ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(progress) == 1 + assert ( + progress[0]["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ) + assert progress[0]["step_id"] == "confirm" + + +async def test_integration_discovery_confirm_creates_entry( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Full path: integration-discovery flow confirmed by the user creates an entry.""" + controller = create_mock_controller("000000002", "192.0.2.2") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": controller.device_uid, + }, + data={CONF_HOST: controller.device_ip}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["result"].unique_id == "000000002" + + +async def test_runtime_integration_discovery_skips_yaml_excluded_uid( + hass: HomeAssistant, +) -> None: + """Deprecated YAML exclude suppresses auto discovery flows.""" + await async_load_yaml_exclude(hass, "000000002") + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + excluded_ctrl = create_mock_controller("000000002", "192.0.2.2") + + with patch( + "homeassistant.helpers.discovery_flow.async_create_flow" + ) as mock_create_flow: + izone_discovery.async_note_integration_discovery(hass, excluded_ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_create_flow.assert_not_called() + + +async def test_runtime_integration_discovery_skips_when_uid_already_configured( + hass: HomeAssistant, +) -> None: + """No active flow remains when a config entry already exists for the UID.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000002", + data={}, + version=2, + ).add_to_hass(hass) + ctrl = create_mock_controller("000000002", "192.0.2.2") + + izone_discovery.async_note_integration_discovery(hass, ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + +async def test_runtime_integration_discovery_skips_for_ignored_unique_id( + hass: HomeAssistant, +) -> None: + """No active flow remains when the UID matches an ignored entry.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000002", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + ctrl = create_mock_controller("000000002", "192.0.2.2") + + izone_discovery.async_note_integration_discovery(hass, ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + +async def test_runtime_integration_discovery_skips_during_user_select_controller_step( + hass: HomeAssistant, +) -> None: + """Do not stack auto discovery while the user is choosing discovered controllers.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + first = create_mock_controller("000000002", "192.0.2.2") + second = create_mock_controller("000000003", "192.0.2.3") + with patch_discovered_controllers([first, second]): + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert user_flow["type"] is FlowResultType.FORM + assert user_flow["step_id"] == "select_controller" + + new_ctrl = create_mock_controller("000000002", "192.0.2.2") + + with patch( + "homeassistant.helpers.discovery_flow.async_create_flow" + ) as mock_create_flow: + izone_discovery.async_note_integration_discovery(hass, new_ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_create_flow.assert_not_called() + + +async def test_runtime_integration_discovery_skips_during_user_confirm( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Runtime discovery stays suppressed while an interactive user flow is active.""" + first = create_mock_controller("000000001", "192.0.2.1") + second = create_mock_controller("000000002", "192.0.2.2") + with patch_discovered_controllers(first): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "confirm" + + izone_discovery.async_note_integration_discovery(hass, second) + await hass.async_block_till_done(wait_background_tasks=True) + + progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(progress) == 1 + assert progress[0]["context"]["source"] == config_entries.SOURCE_USER + + +async def test_async_setup_starts_import_flow(hass: HomeAssistant) -> None: + """Test YAML config triggers an import flow.""" + with ( + patch.object(hass.config_entries.flow, "async_init") as mock_async_init, + patch.object( + hass, + "async_create_task", + side_effect=lambda target: target.close(), + ) as mock_create_task, + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"exclude": []}}) + + mock_async_init.assert_called_once_with( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + mock_create_task.assert_called_once() + + +async def test_async_start_discovery_service_stops_on_home_assistant_stop( + hass: HomeAssistant, + mock_pizone_discovery_service: Mock, +) -> None: + """Test discovery service is stopped on Home Assistant shutdown.""" + with ( + patch( + "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", + return_value=Mock(), + ), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=mock_pizone_discovery_service, + ), + ): + await izone_discovery.async_start_discovery_service(hass) + + assert DATA_DISCOVERY_SERVICE in hass.data + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_pizone_discovery_service.start_discovery.assert_awaited_once() + mock_pizone_discovery_service.close.assert_awaited_once() + assert DATA_DISCOVERY_SERVICE not in hass.data + + +async def test_async_maybe_stop_keeps_running_when_actionable_flow_exists( + hass: HomeAssistant, +) -> None: + """Discovery should stay running while an actionable iZone flow is in progress.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[{"context": {"source": config_entries.SOURCE_USER}}], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_maybe_stop_keeps_running_when_actionable_entry_exists( + hass: HomeAssistant, +) -> None: + """Discovery should stay running while an enabled, non-ignored entry exists.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_USER, + data={}, + ).add_to_hass(hass) + + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop: + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_maybe_stop_stops_when_only_disabled_entry_matches_controller( + hass: HomeAssistant, +) -> None: + """Discovery should stop when only disabled/ignored controllers remain.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_USER, + disabled_by=config_entries.ConfigEntryDisabler.USER, + data={}, + ).add_to_hass(hass) + + service = create_mock_discovery_service(create_mock_controller("000000001")) + hass.data[DATA_DISCOVERY_SERVICE] = service + + with patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop: + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_awaited_once_with(hass) + service.async_schedule_idle_stop.assert_not_called() + + +async def test_async_discover_controllers_starts_shared_service_when_missing( + hass: HomeAssistant, +) -> None: + """Starting discovery without refresh does not trigger extra wait/rescan work.""" + controller = create_mock_controller(device_ip="192.0.2.3") + service = create_mock_discovery_service(controller) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + return_value=service, + ) as mock_start: + controllers = await izone_discovery.async_discover_controllers(hass) + + assert list(controllers) == ["000000001"] + mock_start.assert_awaited_once() + service.pi_disco.fetch_controller.assert_not_awaited() + service.pi_disco.fetch_controllers.assert_awaited_once_with() + + +async def test_async_discover_controllers_refresh_after_start_calls_fetch_controllers( + hass: HomeAssistant, +) -> None: + """Refresh after starting discovery delegates to fetch_controllers with timeout.""" + controller = create_mock_controller(device_ip="192.0.2.3") + service = create_mock_discovery_service(controller) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + return_value=service, + ) as mock_start: + controllers = await izone_discovery.async_discover_controllers( + hass, refresh=True + ) + + assert list(controllers) == ["000000001"] + mock_start.assert_awaited_once() + service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) + + +async def test_async_discover_controllers_refresh_calls_fetch_controllers( + hass: HomeAssistant, +) -> None: + """Refresh without UID delegates to fetch_controllers with timeout.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + controllers = await izone_discovery.async_discover_controllers(hass, refresh=True) + + assert controllers == {} + service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) + + +async def test_async_discover_controllers_waits_for_requested_uid( + hass: HomeAssistant, +) -> None: + """Refresh with wait_for_uid calls fetch_controller and returns all controllers.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + requested = create_mock_controller("000000777", "192.0.2.77") + + async def _fetch_and_add(uid: str, timeout: float | None = None) -> None: + service.pi_disco.controllers[uid] = requested + + service.pi_disco.fetch_controller.side_effect = _fetch_and_add + + controllers = await izone_discovery.async_discover_controllers( + hass, + refresh=True, + wait_for_uid="000000777", + ) + + assert controllers == {requested.device_uid: requested} + service.pi_disco.fetch_controller.assert_awaited_once_with("000000777", timeout=ANY) + + +async def test_async_discover_controllers_returns_empty_when_start_fails( + hass: HomeAssistant, +) -> None: + """Startup errors while creating discovery are propagated to the caller.""" + with ( + patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ), + pytest.raises(OSError), + ): + await izone_discovery.async_discover_controllers(hass, refresh=True) + + +def test_flow_uid_for_matching_returns_none_when_no_uid() -> None: + """Flow UID extraction returns None when context has no unique_id.""" + flow = SimpleNamespace(context={}, init_data=None) + + assert config_flow._flow_uid_for_matching(flow) is None + + +def test_is_matching_returns_false_when_either_flow_has_no_uid() -> None: + """Flow matching should fail when a stable UID cannot be derived.""" + first = SimpleNamespace(context={}, init_data=None) + second = SimpleNamespace(context={"unique_id": "000000222"}, init_data=None) + + assert config_flow.IZoneConfigFlow.is_matching(first, second) is False + + +def test_is_matching_returns_true_for_same_flow_uid() -> None: + """Flow matching should succeed when both flows resolve to the same UID.""" + first = SimpleNamespace(context={"unique_id": "000000111"}, init_data=None) + second = SimpleNamespace(context={"unique_id": "000000111"}, init_data=None) + + assert config_flow.IZoneConfigFlow.is_matching(first, second) is True + + +async def test_homekit_aborts_for_yaml_excluded_uid_without_discovery( + hass: HomeAssistant, +) -> None: + """HomeKit setup aborts immediately for YAML excluded UIDs.""" + await async_load_yaml_exclude(hass, "000000001") + + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers" + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + mock_discover_controllers.assert_not_called() + + +async def test_homekit_aborts_for_ignored_uid( + hass: HomeAssistant, +) -> None: + """HomeKit setup aborts for UIDs that have been ignored.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers" + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_discover_controllers.assert_not_called() + + +async def test_confirm_asserts_when_controller_data_is_missing( + hass: HomeAssistant, +) -> None: + """Confirm asserts when required controller data is unexpectedly missing.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow._discovered_controller_ip = None + with pytest.raises(AssertionError): + await flow.async_step_confirm() + + +async def test_confirm_aborts_when_refresh_discovers_no_controllers( + hass: HomeAssistant, +) -> None: + """Confirm aborts when a follow-up discovery refresh returns no controllers.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + + with patch_discovered_controllers([]): + result = await flow.async_step_confirm({}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_confirm_asserts_when_unique_id_is_not_string( + hass: HomeAssistant, +) -> None: + """Confirm asserts when flow unique_id is unexpectedly not a string.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow.context["unique_id"] = None + + with ( + patch_discovered_controllers(controller), + pytest.raises(AssertionError), + ): + await flow.async_step_confirm({}) + + +async def test_confirm_aborts_when_unique_id_controller_not_found( + hass: HomeAssistant, +) -> None: + """Confirm aborts when the flow UID is not returned by discovery.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow.context["unique_id"] = "000009999" + + with patch_discovered_controllers(controller): + result = await flow.async_step_confirm({}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_confirm_aborts_when_discovery_startup_fails( + hass: HomeAssistant, +) -> None: + """Test confirm step aborts when discovery service cannot start.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + result = await flow.async_step_confirm({}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +def test_filter_yaml_exclude_returns_original_when_no_exclusions( + hass: HomeAssistant, +) -> None: + """YAML exclusion helper returns unchanged mapping when no excludes are present.""" + controllers = {"000000001": create_mock_controller("000000001", "192.0.2.1")} + + assert ( + config_flow.IZoneConfigFlow._filter_yaml_exclude(hass, controllers) + is controllers + ) + + +async def test_filter_yaml_exclude_removes_excluded_controllers( + hass: HomeAssistant, +) -> None: + """YAML exclusion helper removes matching UIDs from discovered controllers.""" + first = create_mock_controller("000000001", "192.0.2.1") + second = create_mock_controller("000000002", "192.0.2.2") + await async_load_yaml_exclude(hass, "000000002") + + filtered = config_flow.IZoneConfigFlow._filter_yaml_exclude( + hass, + {first.device_uid: first, second.device_uid: second}, + ) + + assert filtered == {first.device_uid: first} + + +def test_async_fan_out_skips_uids_already_in_progress() -> None: + """Fan-out should skip scheduling flows for UIDs already in progress.""" + candidate = create_mock_controller("000000002", "192.0.2.2") + fake_flow = SimpleNamespace( + _async_current_ids=Mock(return_value=set()), + _async_in_progress=Mock(return_value=[{"context": {"unique_id": "000000002"}}]), + _async_schedule_integration_discovery_flow=Mock(), + ) + + config_flow.IZoneConfigFlow._async_fan_out_discovered_controllers( + fake_flow, + [candidate], + selected_uid="000000001", + ) + + fake_flow._async_schedule_integration_discovery_flow.assert_not_called() + + +async def test_async_migrate_entry_clears_legacy_data( + hass: HomeAssistant, +) -> None: + """v1→v2 migration clears legacy entry data; UID and title binding is deferred. + + ConfigEntryNotReady retry semantics only work inside async_setup_entry — raising + from async_migrate_entry permanently lands the entry in MIGRATION_ERROR with no + retry path. All network-dependent work is therefore intentionally deferred to + async_setup_entry. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=DOMAIN, + title="iZone Aircon", + data={"host": "192.0.2.1"}, + ) + entry.add_to_hass(hass) + controller = create_mock_controller("000000001") + + with ( + patch_discovered_controllers(controller), patch( "homeassistant.components.izone.climate.async_setup_entry", return_value=True, - ) as mock_setup, - patch( - "homeassistant.components.izone.config_flow.async_start_discovery_service" - ) as start_disco, - patch( - "homeassistant.components.izone.async_start_discovery_service", - return_value=None, ), ): - start_disco.side_effect = _mock_start_discovery(hass, mock_disco) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - # Confirmation form - assert result["type"] is FlowResultType.FORM - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY - + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - mock_setup.assert_called_once() + assert entry.version == 2 + assert entry.data == {} + assert entry.unique_id == "000000001" + assert entry.title == "iZone 000000001" + + +async def test_async_migrate_entry_does_not_raise_on_discovery_failure( + hass: HomeAssistant, +) -> None: + """Migration succeeds without network calls regardless of discovery state. + + The retry-on-not-ready path only works in async_setup_entry; migration never + makes network calls (see test_async_migrate_entry_clears_legacy_data). + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=DOMAIN, + title="iZone Aircon", + data={"host": "192.0.2.1"}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert entry.data == {} + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_async_migrate_entry_does_not_raise_for_multiple_eligible( + hass: HomeAssistant, +) -> None: + """Migration does not raise for multiple eligible controllers. + + The multi-controller failure case is handled in async_setup_entry, not here. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=DOMAIN, + title="iZone Aircon", + data={"host": "192.0.2.1"}, + ) + entry.add_to_hass(hass) + controller1 = create_mock_controller("000000001") + controller2 = create_mock_controller("000000002", "192.0.2.2") + + with patch_discovered_controllers([controller1, controller2]): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert entry.data == {} + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_raises_not_ready_when_discovery_service_fails( + hass: HomeAssistant, +) -> None: + """async_setup_entry raises ConfigEntryNotReady when async_start_discovery_service raises OSError.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id="000000001", + data={CONF_HOST: "192.0.2.1"}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new=AsyncMock(side_effect=OSError), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("initial_title", "expected_title"), + [ + pytest.param("iZone Aircon", "iZone 000000001", id="default_title_updated"), + pytest.param("My AC", "My AC", id="custom_title_preserved"), + ], +) +async def test_setup_entry_resolves_legacy_uid_and_updates_title( + hass: HomeAssistant, + initial_title: str, + expected_title: str, +) -> None: + """Legacy entry has its UID and title resolved at setup time, not migration time.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title=initial_title, + data={}, + ) + entry.add_to_hass(hass) + controller = create_mock_controller("000000001", "192.0.2.2") + + with ( + patch_discovered_controllers(controller), + patch.object( + hass.config_entries, + "async_forward_entry_setups", + new=AsyncMock(return_value=None), + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "000000001" + assert entry.title == expected_title + + +@pytest.mark.parametrize( + ("return_value", "side_effect"), + [ + pytest.param({}, None, id="no_controllers_found"), + pytest.param(None, OSError, id="discovery_oserror"), + ], +) +async def test_setup_entry_raises_not_ready_for_legacy_entry_on_discovery_failure( + hass: HomeAssistant, + return_value: dict | None, + side_effect: type[Exception] | None, +) -> None: + """Legacy entry raises ConfigEntryNotReady when discovery finds nothing or fails. + + Because this is raised from async_setup_entry (not async_migrate_entry), HA + will schedule a retry — unlike the old behaviour where the exception would + permanently land the entry in MIGRATION_ERROR. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + + if side_effect is OSError: + patch_ctx = patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ) + else: + patch_ctx = patch_discovered_controllers([]) + + with patch_ctx: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_raises_config_error_for_legacy_entry_with_multiple_eligible( + hass: HomeAssistant, +) -> None: + """Legacy entry raises ConfigEntryError when multiple controllers are eligible. + + This is a permanent failure for the legacy entry. The controllers are not lost — + the discovery fan-out will surface them as individual flows once HA restarts. + This is not a breaking change: a v1 entry with multiple controllers was already + broken before this PR. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + controllers = { + "000000001": create_mock_controller("000000001", "192.0.2.1"), + "000000002": create_mock_controller("000000002", "192.0.2.2"), + } + + with patch_discovered_controllers( + [controllers["000000001"], controllers["000000002"]] + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + ("excluded_uid", "already_configured_uid"), + [ + pytest.param("000000001", "999999999", id="filtered_by_exclude_list"), + pytest.param("999999999", "000000001", id="filtered_by_configured_entry"), + ], +) +async def test_setup_entry_picks_eligible_controller_after_filtering_for_legacy_entry( + hass: HomeAssistant, + excluded_uid: str, + already_configured_uid: str, +) -> None: + """Legacy entry picks the one controller not filtered out. + + In each case one of two discovered controllers is ineligible — either its + UID is in the exclude list or it is already owned by another config entry. + The dummy UID "999999999" is used for the filter that should have no effect. + """ + await async_load_yaml_exclude(hass, excluded_uid) + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, version=2, unique_id=already_configured_uid, data={} + ).add_to_hass(hass) + controllers = { + "000000001": create_mock_controller("000000001", "192.0.2.1"), + "000000002": create_mock_controller("000000002", "192.0.2.2"), + } + + with ( + patch_discovered_controllers( + [controllers["000000001"], controllers["000000002"]] + ), + patch.object( + hass.config_entries, + "async_forward_entry_setups", + new=AsyncMock(return_value=None), + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "000000002" + assert entry.data == {} + + +@pytest.mark.parametrize( + ("excluded_uid", "already_configured_uid"), + [ + pytest.param("000000001", "999999999", id="all_excluded"), + pytest.param("999999999", "000000001", id="all_already_configured"), + ], +) +async def test_setup_entry_raises_not_ready_for_legacy_entry_when_no_eligible_after_filter( + hass: HomeAssistant, + excluded_uid: str, + already_configured_uid: str, +) -> None: + """Legacy entry raises ConfigEntryNotReady when all controllers are filtered out. + + HA will retry async_setup_entry, giving the user time to resolve the filter + configuration. + """ + await async_load_yaml_exclude(hass, excluded_uid) + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, version=2, unique_id=already_configured_uid, data={} + ).add_to_hass(hass) + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +def test_discovery_listener_methods_dispatch_expected_signals( + hass: HomeAssistant, +) -> None: + """Listener callbacks dispatch the expected Home Assistant signals.""" + service = izone_discovery.DiscoveryService(hass) + controller = create_mock_controller("000000001", "192.0.2.1") + zone = Mock() + err = RuntimeError("boom") + + with patch( + "homeassistant.components.izone.discovery.async_dispatcher_send" + ) as mock_send: + service.controller_disconnected(controller, err) + service.controller_reconnected(controller) + service.controller_update(controller) + service.zone_update(controller, zone) + + assert mock_send.call_args_list == [ + ((hass, izone_discovery.DISPATCH_CONTROLLER_DISCONNECTED, controller, err),), + ((hass, izone_discovery.DISPATCH_CONTROLLER_RECONNECTED, controller),), + ((hass, izone_discovery.DISPATCH_CONTROLLER_UPDATE, controller),), + ((hass, izone_discovery.DISPATCH_ZONE_UPDATE, controller, zone),), + ] + + +def test_controller_discovered_dispatches_signal_and_reschedules_idle_stop( + hass: HomeAssistant, +) -> None: + """Discovered controller should dispatch signal and cancel prior idle-stop handle.""" + service = izone_discovery.DiscoveryService(hass) + previous_handle = Mock() + new_handle = Mock() + service._idle_stop_handle = previous_handle + controller = create_mock_controller("000000001", "192.0.2.1") + + with ( + patch.object(hass.loop, "call_later", return_value=new_handle), + patch( + "homeassistant.components.izone.discovery.async_dispatcher_send" + ) as mock_send, + ): + service.controller_discovered(controller) + + previous_handle.cancel.assert_called_once() + assert service._idle_stop_handle is new_handle + mock_send.assert_called_once_with( + hass, + izone_discovery.DISPATCH_CONTROLLER_DISCOVERED, + controller, + ) + + +async def test_start_discovery_listener_forwards_discovered_controller_to_flow( + hass: HomeAssistant, + mock_pizone_discovery_service: Mock, +) -> None: + """Discovery dispatcher callback should forward discovered controllers to config flow.""" + captured_listener = None + + def _capture_listener(*args: object) -> Mock: + nonlocal captured_listener + captured_listener = args[2] + return Mock() + + controller = create_mock_controller("000000004", "192.0.2.4") + + with ( + patch( + "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", + return_value=Mock(), + ), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=mock_pizone_discovery_service, + ), + patch( + "homeassistant.components.izone.discovery.async_dispatcher_connect", + side_effect=_capture_listener, + ), + patch( + "homeassistant.components.izone.discovery.async_note_integration_discovery" + ) as mock_note, + ): + await izone_discovery.async_start_discovery_service(hass) + assert captured_listener is not None + captured_listener(controller) + + mock_note.assert_called_once_with(hass, controller) + + +async def test_is_ignored_or_excluded_uid_returns_true_for_yaml_exclude( + hass: HomeAssistant, +) -> None: + """UIDs listed in YAML exclude are treated as ignored/excluded.""" + await async_load_yaml_exclude(hass, "000000009") + + assert izone_discovery._async_is_ignored_or_excluded_uid(hass, "000000009") is True + + +async def test_async_maybe_stop_returns_when_service_not_started( + hass: HomeAssistant, +) -> None: + """No-op when maybe-stop is called without a discovery service instance.""" + await izone_discovery.async_maybe_stop_discovery_service(hass) + + +async def test_async_start_discovery_service_returns_existing_instance( + hass: HomeAssistant, +) -> None: + """Starting discovery returns existing service when already running.""" + existing = Mock() + hass.data[DATA_DISCOVERY_SERVICE] = existing + + disco = await izone_discovery.async_start_discovery_service(hass) + + assert disco is existing + + +async def test_async_maybe_stop_stops_when_no_controllers_remain( + hass: HomeAssistant, +) -> None: + """Discovery stops when no controllers are tracked and nothing is actionable.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_awaited_once_with(hass) + + +async def test_async_maybe_stop_keeps_running_when_controller_not_ignored( + hass: HomeAssistant, +) -> None: + """Discovery remains active if at least one discovered controller is still actionable.""" + service = create_mock_discovery_service(create_mock_controller("000000001")) + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_stop_discovery_service_returns_when_not_started( + hass: HomeAssistant, +) -> None: + """Stop is a no-op if discovery service was never started.""" + await izone_discovery.async_stop_discovery_service(hass) + + +async def test_async_stop_discovery_service_clears_stop_listener( + hass: HomeAssistant, +) -> None: + """Stop should remove the stop listener when it exists.""" + service = Mock() + stop_listener = Mock() + service.remove_stop_listener = stop_listener + service.remove_config_flow_listener = None + service.async_cancel_idle_stop = Mock() + service.pi_disco.close = AsyncMock() + hass.data[DATA_DISCOVERY_SERVICE] = service + + await izone_discovery.async_stop_discovery_service(hass) + + stop_listener.assert_called_once() + assert service.remove_stop_listener is None From 66a9108a978b0bf7ecec31a4bcacd71abb1bc1f7 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 10 Jul 2026 20:59:10 +0200 Subject: [PATCH 453/707] Validate bsblan schedule service target devices (#176187) --- homeassistant/components/bsblan/services.py | 20 +++++- homeassistant/components/bsblan/services.yaml | 4 ++ homeassistant/components/bsblan/strings.json | 3 + tests/components/bsblan/test_services.py | 63 ++++++++++++++----- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index f48ea2552213..8f3e46c72a62 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -165,9 +165,27 @@ def _resolve_config_entry( return entry, device_entry +def _device_name(device_entry: dr.DeviceEntry) -> str: + """Return the best available display name for a device.""" + return device_entry.name_by_user or device_entry.name or device_entry.id + + +def _ensure_water_heater_device(device_entry: dr.DeviceEntry) -> None: + """Validate the service targets the water heater sub-device.""" + for domain, identifier in device_entry.identifiers: + if domain == DOMAIN and identifier.endswith("-water-heater"): + return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_a_water_heater_device", + translation_placeholders={"device_name": _device_name(device_entry)}, + ) + + async def set_hot_water_schedule(service_call: ServiceCall) -> None: """Set hot water heating schedule.""" - entry, _ = _resolve_config_entry(service_call) + entry, device_entry = _resolve_config_entry(service_call) + _ensure_water_heater_device(device_entry) client = entry.runtime_data.client days = _build_weekly_schedule_days(service_call) diff --git a/homeassistant/components/bsblan/services.yaml b/homeassistant/components/bsblan/services.yaml index 0844aa35feaa..ccfb91594aa1 100644 --- a/homeassistant/components/bsblan/services.yaml +++ b/homeassistant/components/bsblan/services.yaml @@ -6,6 +6,8 @@ sync_time: selector: device: integration: bsblan + entity: + - domain: button set_hot_water_schedule: fields: @@ -15,6 +17,8 @@ set_hot_water_schedule: selector: device: integration: bsblan + entity: + - domain: water_heater monday_slots: selector: object: diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 425bfd65e97a..e97561661049 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -127,6 +127,9 @@ "no_config_entry_for_device": { "message": "No configuration entry found for device: {device_id}" }, + "not_a_water_heater_device": { + "message": "The selected device ({device_name}) is not a water heater. Please select the water heater sub-device." + }, "set_data_error": { "message": "An error occurred while sending the data to the BSB-LAN device" }, diff --git a/tests/components/bsblan/test_services.py b/tests/components/bsblan/test_services.py index 62f26c97f076..859484bcd21a 100644 --- a/tests/components/bsblan/test_services.py +++ b/tests/components/bsblan/test_services.py @@ -44,6 +44,19 @@ def device_entry( return device +@pytest.fixture +def water_heater_device_entry( + device_registry: dr.DeviceRegistry, + setup_integration: None, +) -> dr.DeviceEntry: + """Get the water heater sub-device entry for testing.""" + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{TEST_DEVICE_MAC}-water-heater")} + ) + assert device is not None + return device + + @pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize( ("service_data", "expected_schedules"), @@ -119,13 +132,13 @@ def device_entry( async def test_set_hot_water_schedule( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, service_data: dict[str, Any], expected_schedules: dict[str, DaySchedule], ) -> None: """Test setting hot water schedule with various configurations.""" # Call the service with device_id and slot fields - service_call_data = {"device_id": device_entry.id} + service_call_data = {"device_id": water_heater_device_entry.id} service_call_data.update(service_data) await hass.services.async_call( @@ -216,7 +229,7 @@ async def test_no_config_entry_for_device( async def test_config_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test error when config entry is not loaded.""" await hass.config_entries.async_unload(mock_config_entry.entry_id) @@ -226,7 +239,7 @@ async def test_config_entry_not_loaded( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": time(6, 0), "end_time": time(8, 0)}, ], @@ -241,12 +254,34 @@ async def test_config_entry_not_loaded( async def test_api_error( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test error when BSB-LAN API call fails.""" mock_bsblan.set_hot_water_schedule.side_effect = BSBLANError("API Error") with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + "set_hot_water_schedule", + { + "device_id": water_heater_device_entry.id, + "monday_slots": [ + {"start_time": time(6, 0), "end_time": time(8, 0)}, + ], + }, + blocking=True, + ) + + assert exc_info.value.translation_key == "set_schedule_failed" + + +@pytest.mark.usefixtures("setup_integration") +async def test_set_hot_water_schedule_rejects_main_device( + hass: HomeAssistant, + device_entry: dr.DeviceEntry, +) -> None: + """Test that picking the main device for hot water schedule is rejected.""" + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "set_hot_water_schedule", @@ -259,7 +294,7 @@ async def test_api_error( blocking=True, ) - assert exc_info.value.translation_key == "set_schedule_failed" + assert exc_info.value.translation_key == "not_a_water_heater_device" @pytest.mark.usefixtures("setup_integration") @@ -276,7 +311,7 @@ async def test_api_error( ) async def test_time_validation_errors( hass: HomeAssistant, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, start_time: time | str, end_time: time | str, expected_error: str, @@ -287,7 +322,7 @@ async def test_time_validation_errors( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": start_time, "end_time": end_time}, ], @@ -302,7 +337,7 @@ async def test_time_validation_errors( async def test_unprovided_days_are_none( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test that unprovided days are sent as None to BSB-LAN API.""" # Only provide Monday and Tuesday, leave other days unprovided @@ -310,7 +345,7 @@ async def test_unprovided_days_are_none( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": time(6, 0), "end_time": time(8, 0)}, ], @@ -346,7 +381,7 @@ async def test_unprovided_days_are_none( async def test_string_time_formats( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test service with string time formats.""" # Test with string time formats @@ -354,7 +389,7 @@ async def test_string_time_formats( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": "06:00:00", "end_time": "08:00:00"}, # With seconds ], @@ -382,7 +417,7 @@ async def test_string_time_formats( @pytest.mark.usefixtures("setup_integration") async def test_non_standard_time_types( hass: HomeAssistant, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test service with non-standard time types raises error.""" # Test with integer time values - schema validation will reject these @@ -391,7 +426,7 @@ async def test_non_standard_time_types( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": 600, "end_time": 800}, ], From 684b3e56edec3ca36b9b52a9ef93b7f57c356408 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 10 Jul 2026 20:59:28 +0200 Subject: [PATCH 454/707] Fix Hitachi Yutaki second heating zone using zone 1 states in Overkiz (#176210) --- .../hitachi_air_to_water_heating_zone.py | 32 +- .../fixtures/setup/cloud_hi_kumo_europe.json | 2088 +++++++++++++++++ .../overkiz/snapshots/test_climate.ambr | 154 ++ .../overkiz/snapshots/test_sensor.ambr | 348 +++ tests/components/overkiz/test_climate.py | 40 +- tests/components/overkiz/test_sensor.py | 7 + 6 files changed, 2659 insertions(+), 10 deletions(-) create mode 100644 tests/components/overkiz/fixtures/setup/cloud_hi_kumo_europe.json diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index c7069fe4ba08..21a4db0e12dc 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -47,6 +47,14 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + # Each zone is its own device; zone 1 is the default, zone 2 overrides below. + _auto_manu_mode_state = OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1 + _room_temperature_state = OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1 + _thermostat_setting_state = OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1 + _set_thermostat_setting_command = ( + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1 + ) + def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator ) -> None: @@ -56,12 +64,24 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): if self._attr_device_info: self._attr_device_info["manufacturer"] = "Hitachi" + if "Zone2" in self.device.controllable_name: + self._auto_manu_mode_state = OverkizState.MODBUS_AUTO_MANU_MODE_ZONE2 + self._room_temperature_state = ( + OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE2 + ) + self._thermostat_setting_state = ( + OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE2 + ) + self._set_thermostat_setting_command = ( + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_2 + ) + @property @override def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" if ( - state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1) + state := self.device.states.get(self._auto_manu_mode_state) ) and state.value_as_str: return OVERKIZ_TO_HVAC_MODE[state.value_as_str] @@ -96,9 +116,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): @override def current_temperature(self) -> float | None: """Return the current temperature.""" - current_temperature = self.device.states.get( - OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1 - ) + current_temperature = self.device.states.get(self._room_temperature_state) if current_temperature: return current_temperature.value_as_float @@ -109,9 +127,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): @override def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - target_temperature = self.device.states.get( - OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1 - ) + target_temperature = self.device.states.get(self._thermostat_setting_state) if target_temperature: return target_temperature.value_as_float @@ -124,5 +140,5 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) await self.executor.async_execute_command( - OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) + self._set_thermostat_setting_command, float(temperature) ) diff --git a/tests/components/overkiz/fixtures/setup/cloud_hi_kumo_europe.json b/tests/components/overkiz/fixtures/setup/cloud_hi_kumo_europe.json new file mode 100644 index 000000000000..c9efa255e289 --- /dev/null +++ b/tests/components/overkiz/fixtures/setup/cloud_hi_kumo_europe.json @@ -0,0 +1,2088 @@ +{ + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "id": "SETUP-1234-5678-2284", + "location": { + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "city": "**", + "country": "**", + "postalCode": "**", + "addressLine1": "**", + "addressLine2": "**", + "timezone": "Europe/Paris", + "longitude": "**", + "latitude": "**", + "twilightMode": 2, + "twilightAngle": "CIVIL", + "twilightCity": "paris", + "summerSolsticeDuskMinutes": 1290, + "winterSolsticeDuskMinutes": 990, + "twilightOffsetEnabled": false, + "dawnOffset": 0, + "duskOffset": 0, + "countryCode": "FR" + }, + "gateways": [ + { + "gatewayId": "1234-5678-2284", + "type": 44, + "subType": 0, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "autoUpdateEnabled": false, + "alive": true, + "timeReliable": true, + "connectivity": { + "status": "OK", + "protocolVersion": "2024.4.3" + }, + "upToDate": true, + "updateStatus": "UP_TO_DATE", + "syncInProgress": false, + "mode": "ACTIVE", + "functions": "INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS" + } + ], + "devices": [ + { + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "label": "Hub", + "deviceURL": "internal://1234-5678-2284/pod/0", + "shortcut": false, + "controllableName": "internal:PodMiniComponent", + "definition": { + "commands": [ + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "update", + "nparams": 0 + }, + { + "commandName": "setCountryCode", + "nparams": 1 + }, + { + "commandName": "activateCalendar", + "nparams": 0 + }, + { + "commandName": "deactivateCalendar", + "nparams": 0 + }, + { + "commandName": "refreshPodMode", + "nparams": 0 + }, + { + "commandName": "refreshUpdateStatus", + "nparams": 0 + }, + { + "commandName": "setCalendar", + "nparams": 1 + }, + { + "commandName": "setLightingLedPodMode", + "nparams": 1 + }, + { + "commandName": "setPodLedOff", + "nparams": 0 + }, + { + "commandName": "setPodLedOn", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["offline", "online"], + "qualifiedName": "core:ConnectivityState" + }, + { + "type": "DataState", + "qualifiedName": "core:CountryCodeState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:LocalAccessProofState" + }, + { + "type": "DataState", + "qualifiedName": "core:LocalIPv4AddressState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": [ + "doublePress", + "longPress", + "simplePress", + "triplePress", + "veryLongPress" + ], + "qualifiedName": "internal:LastActionConfigButtonState" + }, + { + "type": "ContinuousState", + "qualifiedName": "internal:LightingLedPodModeState" + } + ], + "dataProperties": [], + "widgetName": "Pod", + "uiProfiles": ["UpdatableComponent"], + "uiClass": "Pod", + "qualifiedName": "internal:PodMiniComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Hub" + }, + { + "name": "internal:LightingLedPodModeState", + "type": 2, + "value": 1.0 + }, + { + "name": "core:LocalIPv4AddressState", + "type": 3, + "value": "192.168.17.78" + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "Pod", + "type": 1, + "oid": "2b24e929-3fbc-40d4-be50-22720f5edc18", + "uiClass": "Pod" + }, + { + "creationTime": 1681567908000, + "lastUpdateTime": 1681567908000, + "label": "Yutaki", + "deviceURL": "modbus://1234-5678-2284/5416194/1#1", + "shortcut": false, + "controllableName": "modbus:YutakiMainComponent", + "definition": { + "commands": [ + { + "commandName": "refreshEcoModeOffset", + "nparams": 0 + }, + { + "commandName": "refreshEcoModeOffsetTarget", + "nparams": 0 + }, + { + "commandName": "setEcoModeOffset", + "nparams": 1 + }, + { + "commandName": "refreshCentralSetting1", + "nparams": 0 + }, + { + "commandName": "refreshCentralSetting2", + "nparams": 0 + }, + { + "commandName": "refreshCentralSetting3", + "nparams": 0 + }, + { + "commandName": "refreshControlBlockMenu", + "nparams": 0 + }, + { + "commandName": "refreshControlCommunicationAlarmBit", + "nparams": 0 + }, + { + "commandName": "refreshControlUnit", + "nparams": 0 + }, + { + "commandName": "refreshControlUnitMode", + "nparams": 0 + }, + { + "commandName": "refreshHLinkCommunicationAlarm", + "nparams": 0 + }, + { + "commandName": "refreshHardwareVersion", + "nparams": 0 + }, + { + "commandName": "refreshLCDCentralMode", + "nparams": 0 + }, + { + "commandName": "refreshLCDSoftwareNumber", + "nparams": 0 + }, + { + "commandName": "refreshOperationState", + "nparams": 0 + }, + { + "commandName": "refreshOutdoorAmbientTemperature", + "nparams": 0 + }, + { + "commandName": "refreshPCB1SoftwareNumber", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatSetTemperatureC1", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatSetTemperatureC2", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatTemperatureC1", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatTemperatureC2", + "nparams": 0 + }, + { + "commandName": "refreshSoftwareVersion", + "nparams": 0 + }, + { + "commandName": "refreshSpaceMode", + "nparams": 0 + }, + { + "commandName": "refreshStatusBlockMenu", + "nparams": 0 + }, + { + "commandName": "refreshStatusCommunicationAlarmBit", + "nparams": 0 + }, + { + "commandName": "refreshStatusUnitMode", + "nparams": 0 + }, + { + "commandName": "refreshSystemConfiguration", + "nparams": 0 + }, + { + "commandName": "refreshTahomaRoomThermostatAvailable", + "nparams": 0 + }, + { + "commandName": "refreshWaterInletUnitTemperature", + "nparams": 0 + }, + { + "commandName": "refreshWaterOutletUnitTemperature", + "nparams": 0 + }, + { + "commandName": "setCentralSetting1", + "nparams": 1 + }, + { + "commandName": "setCentralSetting2", + "nparams": 1 + }, + { + "commandName": "setCentralSetting3", + "nparams": 1 + }, + { + "commandName": "setControlBlockMenu", + "nparams": 1 + }, + { + "commandName": "setControlCommunicationAlarmBit", + "nparams": 1 + }, + { + "commandName": "setControlUnit", + "nparams": 1 + }, + { + "commandName": "setControlUnitMode", + "nparams": 1 + }, + { + "commandName": "setEcoComfortMode", + "nparams": 4 + }, + { + "commandName": "setGlobalAutoManuMode", + "nparams": 1 + }, + { + "commandName": "setSpaceMode", + "nparams": 1 + }, + { + "commandName": "setTahomaRoomThermostatAvailable", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["auto", "manu"], + "qualifiedName": "core:AutoManuModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CauseOfStoppageState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CentralSetting1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CentralSetting2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CentralSetting3State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CompressorCurrentValueState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CompressorFrequencyState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CompressorRunningCurrentState" + }, + { + "type": "DiscreteState", + "values": ["block", "no"], + "qualifiedName": "modbus:ControlBlockMenuState" + }, + { + "type": "DiscreteState", + "values": ["alarm", "no"], + "qualifiedName": "modbus:ControlCommunicationAlarmBitState" + }, + { + "type": "DiscreteState", + "values": ["cool", "heat"], + "qualifiedName": "modbus:ControlUnitModeState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlUnitState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DefrostingState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DischargeGasTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DischargePressureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DischargeTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "modbus:EcoModeOffsetState" + }, + { + "type": "ContinuousState", + "qualifiedName": "modbus:EcoModeOffsetTargetState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:EvaporatingTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:EvaporationTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:GasTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["alarm", "noAlarm"], + "qualifiedName": "modbus:HLinkCommunicationAlarmState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:HardwareVersionState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:IndoorExpansionValveOpeningState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:IndoorExpansionValveState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:InverterOperationFrequencyState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LCDCentralModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LCDSoftwareNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LiquidTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LiquidTemperatureTHMIState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:MixingValvePositionState" + }, + { + "type": "DiscreteState", + "values": [ + "alarm", + "cool demand-off", + "cool thermo-off", + "cool thermo-on", + "dhw-off", + "heat demand-off", + "heat thermo-off", + "heat thermo-on", + "off", + "swp-off" + ], + "qualifiedName": "modbus:OperationState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:OutdoorAmbientTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:OutdoorExpansionValveState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:OutdoorUnitAmbientState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:PCB1SoftwareNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ProductSpecCodeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RetryCodeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatSetTemperatureC1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatSetTemperatureC2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatTemperatureC1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatTemperatureC2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SecondAmbientAverageTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SecondAmbientTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SoftwareNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SoftwareVersionState" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:SpaceModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusBlockMenuState" + }, + { + "type": "DiscreteState", + "values": ["alarm", "no"], + "qualifiedName": "modbus:StatusCommunicationAlarmBitState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusUnitModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SuctionPressureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SuctionTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SystemConfigurationState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SystemStatus2State" + }, + { + "type": "DiscreteState", + "values": ["available", "not available"], + "qualifiedName": "modbus:TahomaRoomThermostatAvailableState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:UnitModelState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:VirtualEcoComfortModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterFlowLevelState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterInletUnitTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletHpTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletTemperature2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletTemperature3State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletUnitTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterPumpSpeedState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterTemperatureSettingState" + }, + { + "type": "DiscreteState", + "values": ["auto", "holidays", "normal", "timer"], + "qualifiedName": "modbus:YutakiVirtualOperatingModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiAirToWaterMainComponent", + "uiProfiles": ["Specific"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiMainComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:ControlUnitState", + "type": 3, + "value": "run" + }, + { + "name": "modbus:ControlUnitModeState", + "type": 3, + "value": "heat" + }, + { + "name": "modbus:ControlBlockMenuState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ControlCommunicationAlarmBitState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:CentralSetting1State", + "type": 3, + "value": "Unit run/stop : stop -- Unit mode : cool -- Circuit 1 run/stop : stop -- Circuit 2 run/stop : stop -- Circuit 1 room ambient change : false -- Circuit 2 room ambient change : false -- Circuit 1 thermostat setting change : false -- Circuit 2 thermostat setting change : false" + }, + { + "name": "modbus:CentralSetting2State", + "type": 3, + "value": "Circuit 1 Water setting heat : false -- Circuit 2 Water setting heat : false -- Circuit 1 Water setting cool : false -- Circuit 2 Water setting cool : false -- DHWT : off -- Swimming pool : off -- Anti legionella : off" + }, + { + "name": "modbus:CentralSetting3State", + "type": 3, + "value": "Heating OTC 1 : off -- Heating OTC 2 : off -- Cooling OTC 1 : off -- Cooling OTC 2 : off -- DHWT : comfort -- Space mode : standard" + }, + { + "name": "modbus:SpaceModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:TahomaRoomThermostatAvailableState", + "type": 3, + "value": "not available" + }, + { + "name": "modbus:EcoModeOffsetTargetState", + "type": 1, + "value": 5 + }, + { + "name": "modbus:StatusUnitModeState", + "type": 3, + "value": "heat" + }, + { + "name": "modbus:StatusBlockMenuState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:StatusCommunicationAlarmBitState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:LCDCentralModeState", + "type": 1, + "value": 1 + }, + { + "name": "modbus:SystemConfigurationState", + "type": 3, + "value": "Zone 1 heating : available -- Zone 2 heating : available -- Zone 1 cooling : unavailable -- Zone 2 cooling : unavailable -- DHWT : available -- Swimming pool : unavailable -- Room thermostat zone 1 : available -- Room thermostat zone 2 : available" + }, + { + "name": "modbus:OperationState", + "type": 3, + "value": "heat thermo-on" + }, + { + "name": "modbus:OutdoorAmbientTemperatureState", + "type": 1, + "value": -1 + }, + { + "name": "modbus:WaterInletUnitTemperatureState", + "type": 1, + "value": 32 + }, + { + "name": "modbus:WaterOutletUnitTemperatureState", + "type": 1, + "value": 34 + }, + { + "name": "modbus:HardwareVersionState", + "type": 1, + "value": 1 + }, + { + "name": "modbus:SoftwareVersionState", + "type": 1, + "value": 104 + }, + { + "name": "modbus:HLinkCommunicationAlarmState", + "type": 3, + "value": "noAlarm" + }, + { + "name": "modbus:LCDSoftwareNumberState", + "type": 1, + "value": 258 + }, + { + "name": "modbus:PCB1SoftwareNumberState", + "type": 1, + "value": 222 + }, + { + "name": "modbus:RoomThermostatSetTemperatureC1State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomThermostatSetTemperatureC2State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomThermostatTemperatureC1State", + "type": 2, + "value": 18.5 + }, + { + "name": "modbus:RoomThermostatTemperatureC2State", + "type": 2, + "value": 20.5 + }, + { + "name": "modbus:EcoModeOffsetState", + "type": 1, + "value": 5 + }, + { + "name": "modbus:WaterOutletHpTemperatureState", + "type": 1, + "value": 34 + }, + { + "name": "modbus:OutdoorUnitAmbientState", + "type": 1, + "value": -1 + }, + { + "name": "modbus:SecondAmbientTemperatureState", + "type": 1, + "value": -127 + }, + { + "name": "modbus:SecondAmbientAverageTemperatureState", + "type": 1, + "value": -127 + }, + { + "name": "modbus:WaterOutletTemperature2State", + "type": 1, + "value": 32 + }, + { + "name": "modbus:WaterOutletTemperature3State", + "type": 1, + "value": -127 + }, + { + "name": "modbus:GasTemperatureState", + "type": 1, + "value": 38 + }, + { + "name": "modbus:LiquidTemperatureTHMIState", + "type": 1, + "value": 29 + }, + { + "name": "modbus:DischargeGasTemperatureState", + "type": 1, + "value": 47 + }, + { + "name": "modbus:EvaporationTemperatureState", + "type": 1, + "value": -11 + }, + { + "name": "modbus:IndoorExpansionValveState", + "type": 1, + "value": 35 + }, + { + "name": "modbus:OutdoorExpansionValveState", + "type": 1, + "value": 8 + }, + { + "name": "modbus:InverterOperationFrequencyState", + "type": 1, + "value": 50 + }, + { + "name": "modbus:CauseOfStoppageState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:CompressorRunningCurrentState", + "type": 1, + "value": 3 + }, + { + "name": "modbus:ProductSpecCodeState", + "type": 1, + "value": 40 + }, + { + "name": "modbus:MixingValvePositionState", + "type": 1, + "value": 100 + }, + { + "name": "modbus:DefrostingState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:UnitModelState", + "type": 1, + "value": 1 + }, + { + "name": "modbus:WaterTemperatureSettingState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:WaterFlowLevelState", + "type": 2, + "value": 1.9 + }, + { + "name": "modbus:WaterPumpSpeedState", + "type": 1, + "value": 80 + }, + { + "name": "modbus:SystemStatus2State", + "type": 1, + "value": 44 + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:DischargeTemperatureState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:SuctionTemperatureState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:LiquidTemperatureState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:EvaporatingTemperatureState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:DischargePressureState", + "type": 1, + "value": 90 + }, + { + "name": "modbus:SuctionPressureState", + "type": 1, + "value": 66 + }, + { + "name": "modbus:CompressorFrequencyState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:IndoorExpansionValveOpeningState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:CompressorCurrentValueState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:SoftwareNumberState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:RetryCodeState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiAirToWaterMainComponent", + "type": 1, + "oid": "1bbc9636-4347-4c71-be89-2164466c92c2", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Domestic Hot Water Energy", + "deviceURL": "modbus://1234-5678-2284/5416194/1#10", + "shortcut": false, + "controllableName": "modbus:YutakiV2DHWElectricalEnergyConsumptionComponent", + "definition": { + "commands": [], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricEnergyConsumptionState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricPowerConsumptionState" + } + ], + "dataProperties": [], + "widgetName": "CumulativeElectricPowerConsumptionSensor", + "uiProfiles": [ + "ElectricEnergyAndPower", + "ElectricPowerMeter", + "ElectricEnergyConsumption" + ], + "uiClass": "ElectricitySensor", + "qualifiedName": "modbus:YutakiV2DHWElectricalEnergyConsumptionComponent", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:ElectricEnergyConsumptionState", + "type": 1, + "value": 27857 + }, + { + "name": "core:ElectricPowerConsumptionState", + "type": 1, + "value": 5885 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "CumulativeElectricPowerConsumptionSensor", + "type": 2, + "oid": "57f30fb2-0c35-4bed-9f36-3f84d0c00b3e", + "uiClass": "ElectricitySensor" + }, + { + "creationTime": 1683653416000, + "lastUpdateTime": 1683653416000, + "label": "Yutaki Zone 1", + "deviceURL": "modbus://1234-5678-2284/5416194/1#2", + "shortcut": false, + "controllableName": "modbus:YutakiV2Zone1Component", + "definition": { + "commands": [ + { + "commandName": "refreshMode", + "nparams": 0 + }, + { + "commandName": "globalControl", + "nparams": 3 + }, + { + "commandName": "refreshControlCircuit1", + "nparams": 0 + }, + { + "commandName": "refreshControlCoolingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshControlHeatingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureStatusZone1", + "nparams": 0 + }, + { + "commandName": "refreshStatusCircuit1", + "nparams": 0 + }, + { + "commandName": "refreshStatusCoolingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshStatusHeatingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingStatusZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureStatusZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureStatusZone1", + "nparams": 0 + }, + { + "commandName": "setAutoManuMode", + "nparams": 1 + }, + { + "commandName": "setControlCircuit1", + "nparams": 1 + }, + { + "commandName": "setControlCoolingOTCZone1", + "nparams": 1 + }, + { + "commandName": "setControlHeatingOTCZone1", + "nparams": 1 + }, + { + "commandName": "setHolidayMode", + "nparams": 1 + }, + { + "commandName": "setRoomAmbientTemperatureControlZone1", + "nparams": 1 + }, + { + "commandName": "setTargetMode", + "nparams": 1 + }, + { + "commandName": "setThermostatSettingControlZone1", + "nparams": 1 + }, + { + "commandName": "setWaterCoolingSettingTemperatureControlZone1", + "nparams": 1 + }, + { + "commandName": "setWaterHeatingSettingTemperatureControlZone1", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DiscreteState", + "values": ["auto", "manu"], + "qualifiedName": "modbus:AutoManuModeZone1State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlCircuit1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:ControlCoolingOTCZone1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:ControlHeatingOTCZone1State" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "modbus:HolidayModeZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureStatusZone1State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:StatusCircuit1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:StatusCoolingOTCZone1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:StatusHeatingOTCZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingStatusZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureStatusZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureStatusZone1State" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiModeState" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiTargetModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiAirToWaterHeatingZone", + "uiProfiles": ["Specific"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiV2Zone1Component", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:AutoManuModeZone1State", + "type": 3, + "value": "auto" + }, + { + "name": "modbus:HolidayModeZone1State", + "type": 3, + "value": "off" + }, + { + "name": "modbus:ControlCircuit1State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:ControlHeatingOTCZone1State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:ControlCoolingOTCZone1State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingControlZone1State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureControlZone1State", + "type": 2, + "value": 18.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureControlZone1State", + "type": 1, + "value": 40 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureControlZone1State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:StatusCircuit1State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:StatusHeatingOTCZone1State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:StatusCoolingOTCZone1State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingStatusZone1State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureStatusZone1State", + "type": 2, + "value": 18.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureStatusZone1State", + "type": 1, + "value": 40 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureStatusZone1State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:YutakiTargetModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:YutakiModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiAirToWaterHeatingZone", + "type": 1, + "oid": "d50e4e55-316d-4372-9c62-682d04a0c932", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1683653416000, + "lastUpdateTime": 1683653416000, + "label": "Yutaki Zone 2", + "deviceURL": "modbus://1234-5678-2284/5416194/1#3", + "shortcut": false, + "controllableName": "modbus:YutakiV2Zone2Component", + "definition": { + "commands": [ + { + "commandName": "refreshMode", + "nparams": 0 + }, + { + "commandName": "globalControl", + "nparams": 3 + }, + { + "commandName": "refreshControlCircuit2", + "nparams": 0 + }, + { + "commandName": "refreshControlCoolingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshControlHeatingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureStatusZone2", + "nparams": 0 + }, + { + "commandName": "refreshStatusCircuit2", + "nparams": 0 + }, + { + "commandName": "refreshStatusCoolingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshStatusHeatingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingStatusZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureStatusZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureStatusZone2", + "nparams": 0 + }, + { + "commandName": "setAutoManuMode", + "nparams": 1 + }, + { + "commandName": "setControlCircuit2", + "nparams": 1 + }, + { + "commandName": "setControlCoolingOTCZone2", + "nparams": 1 + }, + { + "commandName": "setControlHeatingOTCZone2", + "nparams": 1 + }, + { + "commandName": "setHolidayMode", + "nparams": 1 + }, + { + "commandName": "setRoomAmbientTemperatureControlZone2", + "nparams": 1 + }, + { + "commandName": "setTargetMode", + "nparams": 1 + }, + { + "commandName": "setThermostatSettingControlZone2", + "nparams": 1 + }, + { + "commandName": "setWaterCoolingSettingTemperatureControlZone2", + "nparams": 1 + }, + { + "commandName": "setWaterHeatingSettingTemperatureControlZone2", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DiscreteState", + "values": ["auto", "manu"], + "qualifiedName": "modbus:AutoManuModeZone2State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlCircuit2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:ControlCoolingOTCZone2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:ControlHeatingOTCZone2State" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "modbus:HolidayModeZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureStatusZone2State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:StatusCircuit2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:StatusCoolingOTCZone2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:StatusHeatingOTCZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingStatusZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureStatusZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureStatusZone2State" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiModeState" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiTargetModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiAirToWaterHeatingZone", + "uiProfiles": ["Specific"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiV2Zone2Component", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:AutoManuModeZone2State", + "type": 3, + "value": "auto" + }, + { + "name": "modbus:HolidayModeZone2State", + "type": 3, + "value": "off" + }, + { + "name": "modbus:ControlCircuit2State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:ControlHeatingOTCZone2State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:ControlCoolingOTCZone2State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingControlZone2State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureControlZone2State", + "type": 2, + "value": 20.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureControlZone2State", + "type": 1, + "value": 38 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureControlZone2State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:StatusCircuit2State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:StatusHeatingOTCZone2State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:StatusCoolingOTCZone2State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingStatusZone2State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureStatusZone2State", + "type": 2, + "value": 20.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureStatusZone2State", + "type": 1, + "value": 38 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureStatusZone2State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:YutakiTargetModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:YutakiModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiAirToWaterHeatingZone", + "type": 1, + "oid": "f7acbaff-8c94-423b-8822-b62876771481", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Domestic Hot Water", + "deviceURL": "modbus://1234-5678-2284/5416194/1#4", + "shortcut": false, + "controllableName": "modbus:YutakiV2DHWTComponent", + "definition": { + "commands": [ + { + "commandName": "refreshBoostMode", + "nparams": 0 + }, + { + "commandName": "globalControlDHW", + "nparams": 3 + }, + { + "commandName": "refreshControlAntiLegionella", + "nparams": 0 + }, + { + "commandName": "refreshControlAntiLegionellaSettingTemperature", + "nparams": 0 + }, + { + "commandName": "refreshControlDHW", + "nparams": 0 + }, + { + "commandName": "refreshControlDHWSettingTemperature", + "nparams": 0 + }, + { + "commandName": "refreshDHWMode", + "nparams": 0 + }, + { + "commandName": "refreshDHWTTemperature", + "nparams": 0 + }, + { + "commandName": "refreshStatusAntiLegionella", + "nparams": 0 + }, + { + "commandName": "refreshStatusAntiLegionellaSettingTemperature", + "nparams": 0 + }, + { + "commandName": "refreshStatusDHW", + "nparams": 0 + }, + { + "commandName": "refreshStatusDHWSettingTemperature", + "nparams": 0 + }, + { + "commandName": "setControlAntiLegionella", + "nparams": 1 + }, + { + "commandName": "setControlAntiLegionellaSettingTemperature", + "nparams": 1 + }, + { + "commandName": "setControlDHW", + "nparams": 1 + }, + { + "commandName": "setControlDHWSettingTemperature", + "nparams": 1 + }, + { + "commandName": "setDHWMode", + "nparams": 1 + }, + { + "commandName": "setTargetBoostMode", + "nparams": 1 + } + ], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:DHWTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ControlAntiLegionellaSettingTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlAntiLegionellaState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ControlDHWSettingTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlDHWState" + }, + { + "type": "DiscreteState", + "values": ["high demand", "standard"], + "qualifiedName": "modbus:DHWModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusAntiLegionellaSettingTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:StatusAntiLegionellaState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusDHWSettingTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusDHWState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:VirtualGlobalControlState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "modbus:YutakiBoostModeState" + }, + { + "type": "DiscreteState", + "values": ["high demand", "off", "standard"], + "qualifiedName": "modbus:YutakiDHWVirtualOperatingModeState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "modbus:YutakiTargetBoostModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiDHW", + "uiProfiles": ["DHWTemperature"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiV2DHWTComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:ControlDHWState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:ControlDHWSettingTemperatureState", + "type": 1, + "value": 30 + }, + { + "name": "modbus:ControlAntiLegionellaState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:ControlAntiLegionellaSettingTemperatureState", + "type": 1, + "value": 55 + }, + { + "name": "modbus:DHWModeState", + "type": 3, + "value": "standard" + }, + { + "name": "modbus:StatusDHWState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:StatusDHWSettingTemperatureState", + "type": 1, + "value": 30 + }, + { + "name": "modbus:StatusAntiLegionellaState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:StatusAntiLegionellaSettingTemperatureState", + "type": 1, + "value": 55 + }, + { + "name": "core:DHWTemperatureState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:YutakiTargetBoostModeState", + "type": 3, + "value": "no request" + }, + { + "name": "modbus:YutakiBoostModeState", + "type": 3, + "value": "disabled" + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiDHW", + "type": 1, + "oid": "c121ba66-a938-42c4-b0df-e4ad3b199047", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Room Thermostat Zone 1", + "deviceURL": "modbus://1234-5678-2284/5416194/1#6", + "shortcut": false, + "controllableName": "modbus:YutakiRoomThermostatZone1Component", + "definition": { + "commands": [], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + } + ], + "dataProperties": [], + "widgetName": "HitachiThermostat", + "uiProfiles": ["Specific"], + "uiClass": "HeatingSystem", + "qualifiedName": "modbus:YutakiRoomThermostatZone1Component", + "type": "SENSOR" + }, + "states": [ + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiThermostat", + "type": 2, + "oid": "90ecec6f-6e7d-4b75-9d86-8eafc77aad8c", + "uiClass": "HeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Room Thermostat Zone 2", + "deviceURL": "modbus://1234-5678-2284/5416194/1#7", + "shortcut": false, + "controllableName": "modbus:YutakiRoomThermostatZone2Component", + "definition": { + "commands": [], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + } + ], + "dataProperties": [], + "widgetName": "HitachiThermostat", + "uiProfiles": ["Specific"], + "uiClass": "HeatingSystem", + "qualifiedName": "modbus:YutakiRoomThermostatZone2Component", + "type": "SENSOR" + }, + "states": [ + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiThermostat", + "type": 2, + "oid": "8f85079d-ae96-4246-b9e4-d59f60f41a12", + "uiClass": "HeatingSystem" + }, + { + "creationTime": 1683653416000, + "lastUpdateTime": 1683653416000, + "label": "Space Heating Energy", + "deviceURL": "modbus://1234-5678-2284/5416194/1#9", + "shortcut": false, + "controllableName": "modbus:YutakiV2SpaceHeatingElectricalEnergyConsumptionComponent", + "definition": { + "commands": [], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricEnergyConsumptionState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricPowerConsumptionState" + } + ], + "dataProperties": [], + "widgetName": "CumulativeElectricPowerConsumptionSensor", + "uiProfiles": [ + "ElectricEnergyAndPower", + "ElectricPowerMeter", + "ElectricEnergyConsumption" + ], + "uiClass": "ElectricitySensor", + "qualifiedName": "modbus:YutakiV2SpaceHeatingElectricalEnergyConsumptionComponent", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:ElectricEnergyConsumptionState", + "type": 1, + "value": 41668 + }, + { + "name": "core:ElectricPowerConsumptionState", + "type": 1, + "value": 5554 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "CumulativeElectricPowerConsumptionSensor", + "type": 2, + "oid": "20a30518-2348-474b-8877-800945e253c0", + "uiClass": "ElectricitySensor" + }, + { + "creationTime": 1681567906000, + "lastUpdateTime": 1681567906000, + "label": "Modbus Controller", + "deviceURL": "ovp://1234-5678-2284/5416194", + "shortcut": false, + "controllableName": "ovp:ModbusMainController", + "definition": { + "commands": [ + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "unknown", + "uiProfiles": ["Specific"], + "uiClass": "Generic", + "qualifiedName": "ovp:ModbusMainController", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Modbus Controller" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "unknown", + "type": 1, + "oid": "6e64bc4d-3bfe-4f7f-8170-ee0426ab4eb9", + "uiClass": "Generic" + } + ], + "zones": [], + "resellerDelegationType": "NEVER", + "oid": "1680fbe4-2a2f-44f9-a37b-18398e1dc4de", + "rootPlace": { + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "label": "My House", + "type": 0, + "oid": "39502b85-28fa-4bab-9535-7dccae4589ac", + "subPlaces": [] + }, + "features": [] +} diff --git a/tests/components/overkiz/snapshots/test_climate.ambr b/tests/components/overkiz/snapshots/test_climate.ambr index f86f59749643..0e37f35b9f6b 100644 --- a/tests/components/overkiz/snapshots/test_climate.ambr +++ b/tests/components/overkiz/snapshots/test_climate.ambr @@ -93,6 +93,160 @@ 'state': 'heat', }) # --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + ]), + : 35.0, + : 5.0, + : list([ + 'comfort', + 'eco', + ]), + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Yutaki Zone 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Yutaki Zone 1', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'modbus://1234-5678-2284/5416194/1#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 18.5, + : 'Somfy TaHoma Switch Yutaki Zone 1', + : list([ + , + , + ]), + : 35.0, + : 5.0, + : 'comfort', + : list([ + 'comfort', + 'eco', + ]), + : , + : 0.5, + : 21.0, + }), + 'context': , + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + ]), + : 35.0, + : 5.0, + : list([ + 'comfort', + 'eco', + ]), + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Yutaki Zone 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Yutaki Zone 2', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'modbus://1234-5678-2284/5416194/1#3', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 20.5, + : 'Somfy TaHoma Switch Yutaki Zone 2', + : list([ + , + , + ]), + : 35.0, + : 5.0, + : 'comfort', + : list([ + 'comfort', + 'eco', + ]), + : , + : 0.5, + : 21.0, + }), + 'context': , + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- # name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_garden_radiator-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/snapshots/test_sensor.ambr b/tests/components/overkiz/snapshots/test_sensor.ambr index 8c8ec832617a..81ec3e71ff7f 100644 --- a/tests/components/overkiz/snapshots/test_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_sensor.ambr @@ -1544,6 +1544,354 @@ 'state': '44962', }) # --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Domestic Hot Water Energy Electric energy consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Domestic Hot Water Energy Electric energy consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#10-core:ElectricEnergyConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Yutaki Domestic Hot Water Energy Electric energy consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27857', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Domestic Hot Water Energy Electric power consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Domestic Hot Water Energy Electric power consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#10-core:ElectricPowerConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Yutaki Domestic Hot Water Energy Electric power consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5885', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_outdoor_ambient_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_outdoor_ambient_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor ambient temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor ambient temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#1-modbus:OutdoorAmbientTemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_outdoor_ambient_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Yutaki Outdoor ambient temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_outdoor_ambient_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Space Heating Energy Electric energy consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Space Heating Energy Electric energy consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#9-core:ElectricEnergyConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Yutaki Space Heating Energy Electric energy consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41668', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Space Heating Energy Electric power consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Space Heating Energy Electric power consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#9-core:ElectricPowerConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Yutaki Space Heating Energy Electric power consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5554', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Yutaki Zone 1 Room ambient temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yutaki Zone 1 Room ambient temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#2-modbus:RoomAmbientTemperatureStatusZone1State', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Yutaki Yutaki Zone 1 Room ambient temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- # name: test_sensor_entities_snapshot[cloud_nexity_rail_din_europe.json][sensor.maple_residence_garage_ceiling_light_discrete_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py index cddb4ad4586a..74a47ca41ed0 100644 --- a/tests/components/overkiz/test_climate.py +++ b/tests/components/overkiz/test_climate.py @@ -10,8 +10,13 @@ from pyoverkiz.models import Event import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ATTR_HVAC_ACTION, HVACAction -from homeassistant.const import Platform +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -39,9 +44,22 @@ COZYTOUCH = FixtureDevice( "climate.living_room_heater", ) +# Hitachi Yutaki 2-zone air-to-water heat pump +YUTAKI_ZONE_1 = FixtureDevice( + "setup/cloud_hi_kumo_europe.json", + "modbus://1234-5678-2284/5416194/1#2", + "climate.somfy_tahoma_switch_yutaki_zone_1", +) +YUTAKI_ZONE_2 = FixtureDevice( + "setup/cloud_hi_kumo_europe.json", + "modbus://1234-5678-2284/5416194/1#3", + "climate.somfy_tahoma_switch_yutaki_zone_2", +) + SNAPSHOT_FIXTURES = [ VALVE, COZYTOUCH, + YUTAKI_ZONE_1, ] @@ -142,3 +160,21 @@ async def test_events_for_unknown_device_url( # Should not crash; valve entity should still be available state = hass.states.get(VALVE.entity_id) assert state is not None + + +async def test_hitachi_air_to_water_heating_zone_2( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """Test the second heating zone reads its own Zone2 states.""" + await setup_overkiz_integration(fixture=YUTAKI_ZONE_2.fixture) + + zone_1 = hass.states.get(YUTAKI_ZONE_1.entity_id) + assert zone_1 is not None + assert zone_1.attributes[ATTR_CURRENT_TEMPERATURE] == 18.5 + + zone_2 = hass.states.get(YUTAKI_ZONE_2.entity_id) + assert zone_2 is not None + assert zone_2.state == HVACMode.AUTO + assert zone_2.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5 + assert zone_2.attributes[ATTR_TEMPERATURE] == 21.0 diff --git a/tests/components/overkiz/test_sensor.py b/tests/components/overkiz/test_sensor.py index 8d339f13ed48..63a104b81f75 100644 --- a/tests/components/overkiz/test_sensor.py +++ b/tests/components/overkiz/test_sensor.py @@ -48,12 +48,19 @@ COZYTOUCH_DHW = FixtureDevice( "io://1234-5678-5643/109286#2", "sensor.my_home_patio_water_heating_office_energy_meter_electric_energy_consumption", ) +# Hitachi Yutaki heat pump exposing energy and temperature sensors +YUTAKI = FixtureDevice( + "setup/cloud_hi_kumo_europe.json", + "modbus://1234-5678-2284/5416194/1#1", + "sensor.my_house_yutaki_outdoor_ambient_temperature", +) SNAPSHOT_FIXTURES = [ TEMPERATURE_SENSOR, TEMPERATURE_SENSOR_LOCAL, HOMEKIT_STACK, COZYTOUCH_DHW, + YUTAKI, ] From fab48092015a7eb3ed9f88e8374c8183e3412129 Mon Sep 17 00:00:00 2001 From: Malene Trab Date: Fri, 10 Jul 2026 20:59:42 +0200 Subject: [PATCH 455/707] Add Fuelprices.dk (#163932) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/fuelprices_dk/__init__.py | 53 +++ .../components/fuelprices_dk/config_flow.py | 369 +++++++++++++++ .../components/fuelprices_dk/const.py | 10 + .../components/fuelprices_dk/coordinator.py | 64 +++ .../components/fuelprices_dk/manifest.json | 11 + .../fuelprices_dk/quality_scale.yaml | 86 ++++ .../components/fuelprices_dk/sensor.py | 103 ++++ .../components/fuelprices_dk/strings.json | 94 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/fuelprices_dk/__init__.py | 12 + tests/components/fuelprices_dk/conftest.py | 81 ++++ .../fuelprices_dk/snapshots/test_sensor.ambr | 166 +++++++ .../fuelprices_dk/test_config_flow.py | 447 ++++++++++++++++++ tests/components/fuelprices_dk/test_init.py | 147 ++++++ tests/components/fuelprices_dk/test_sensor.py | 97 ++++ 18 files changed, 1752 insertions(+) create mode 100644 homeassistant/components/fuelprices_dk/__init__.py create mode 100644 homeassistant/components/fuelprices_dk/config_flow.py create mode 100644 homeassistant/components/fuelprices_dk/const.py create mode 100644 homeassistant/components/fuelprices_dk/coordinator.py create mode 100644 homeassistant/components/fuelprices_dk/manifest.json create mode 100644 homeassistant/components/fuelprices_dk/quality_scale.yaml create mode 100644 homeassistant/components/fuelprices_dk/sensor.py create mode 100644 homeassistant/components/fuelprices_dk/strings.json create mode 100644 tests/components/fuelprices_dk/__init__.py create mode 100644 tests/components/fuelprices_dk/conftest.py create mode 100644 tests/components/fuelprices_dk/snapshots/test_sensor.ambr create mode 100644 tests/components/fuelprices_dk/test_config_flow.py create mode 100644 tests/components/fuelprices_dk/test_init.py create mode 100644 tests/components/fuelprices_dk/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index cedde68ab496..dc40b4e6f35a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -607,6 +607,8 @@ CLAUDE.md @home-assistant/core /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs /tests/components/frontier_silicon/ @wlcrs +/homeassistant/components/fuelprices_dk/ @MTrab +/tests/components/fuelprices_dk/ @MTrab /homeassistant/components/fujitsu_fglair/ @crevetor /tests/components/fujitsu_fglair/ @crevetor /homeassistant/components/fully_kiosk/ @cgarwood diff --git a/homeassistant/components/fuelprices_dk/__init__.py b/homeassistant/components/fuelprices_dk/__init__.py new file mode 100644 index 000000000000..a26a8552e228 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/__init__.py @@ -0,0 +1,53 @@ +"""Initialize the Fuelprices.dk component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_COMPANY, CONF_STATION, SUBENTRY_TYPE_STATION +from .coordinator import FuelPricesDKCoordinator + +PLATFORMS = [Platform.SENSOR] + +type FuelpricesDkConfigEntry = ConfigEntry[dict[str, FuelPricesDKCoordinator]] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry +) -> bool: + """Set up Fuelprices.dk from a config entry.""" + config_entry.async_on_unload(config_entry.add_update_listener(_update_listener)) + api_key = config_entry.data[CONF_API_KEY] + runtime_data: dict[str, FuelPricesDKCoordinator] = {} + + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_STATION): + subentry_id = subentry.subentry_id + company = subentry.data[CONF_COMPANY] + station = subentry.data[CONF_STATION] + + coordinator = FuelPricesDKCoordinator( + hass, + api_key, + company, + station, + subentry_id, + config_entry, + ) + runtime_data[subentry_id] = coordinator + await coordinator.async_config_entry_first_refresh() + + config_entry.runtime_data = runtime_data + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def _update_listener(hass: HomeAssistant, entry: FuelpricesDkConfigEntry) -> None: + """Handle options or subentry updates by reloading the entry.""" + hass.config_entries.async_schedule_reload(entry.entry_id) + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/fuelprices_dk/config_flow.py b/homeassistant/components/fuelprices_dk/config_flow.py new file mode 100644 index 000000000000..e3acf6563beb --- /dev/null +++ b/homeassistant/components/fuelprices_dk/config_flow.py @@ -0,0 +1,369 @@ +"""Config flow for the Fuelprices.dk integration.""" + +from collections.abc import Mapping +from typing import Any, override + +from aiohttp import ClientResponseError +from pybraendstofpriser import Braendstofpriser +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback + +from .const import CONF_COMPANY, CONF_STATION, DOMAIN, WEBSITE_URL + + +def _get_api_error_key(exc: ClientResponseError) -> str: + """Map API errors to config flow errors.""" + if exc.status == 401: + return "invalid_api_key" + if exc.status == 429: + return "rate_limit_exceeded" + return "cannot_connect" + + +class FuelpricesDkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fuelprices.dk.""" + + VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"station": FuelpricesDkStationSubentryFlow} + + def __init__(self) -> None: + """Initialize the config flow.""" + self.api: Braendstofpriser + self.companies: list[dict[str, Any]] = [] + self.stations: Any = {} + self.company_name = "" + self.user_input: dict[str, Any] = {} + + async def _async_validate_api_key( + self, api_key: str + ) -> tuple[Braendstofpriser | None, list[dict[str, Any]], str | None]: + """Validate the API key and fetch available companies.""" + api = Braendstofpriser(api_key) + try: + companies = await api.list_companies() + except ClientResponseError as exc: + return None, [], _get_api_error_key(exc) + + if not companies: + return None, [], "cannot_connect" + + return api, companies, None + + async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]: + """Fetch stations for a company.""" + try: + stations = await self.api.list_stations(company_name=company_name) + except ClientResponseError as exc: + return None, _get_api_error_key(exc) + + if not stations: + return None, "cannot_connect" + + return stations, None + + def _show_company_selection_form(self, errors: dict[str, str]) -> ConfigFlowResult: + """Show the company selection form.""" + return self.async_show_form( + step_id="company_selection", + data_schema=vol.Schema( + { + vol.Required(CONF_COMPANY, default=self.company_name): vol.In( + [c["company"] for c in self.companies] + ), + } + ), + errors=errors, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - Enter API key.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match(user_input) + api, companies, error = await self._async_validate_api_key( + user_input[CONF_API_KEY] + ) + if error is None: + assert api is not None + self.api = api + self.companies = companies + self.user_input = dict(user_input) + return await self.async_step_company_selection() + + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + description_placeholders={"website_url": WEBSITE_URL}, + ) + + async def async_step_company_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the company selection step.""" + if user_input is not None: + self.company_name = user_input[CONF_COMPANY] + self.user_input.update(user_input) + self.stations = {} + return await self.async_step_station_selection() + + return self._show_company_selection_form({}) + + async def async_step_station_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the station selection step.""" + if not self.stations: + stations, error = await self._async_fetch_stations(self.company_name) + if error is not None: + return self._show_company_selection_form({"base": error}) + self.stations = stations + + if user_input is not None: + user_input[CONF_STATION] = self.stations.find( + "name", user_input[CONF_STATION] + ) + + # Create the main config entry with the first station subentry + self.user_input.update(user_input) + unique_id = ( + f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}" + ) + title = ( + f"{self.user_input[CONF_COMPANY]} - " + f"{self.user_input[CONF_STATION]['name']}" + ) + return self.async_create_entry( + title="Fuelprices.dk", + data={CONF_API_KEY: self.user_input[CONF_API_KEY]}, + subentries=[ + { + "subentry_type": "station", + "data": { + CONF_COMPANY: self.user_input[CONF_COMPANY], + CONF_STATION: self.user_input[CONF_STATION], + }, + "title": title, + "unique_id": unique_id, + } + ], + ) + + stations = [s["name"] for s in self.stations] + + return self.async_show_form( + step_id="station_selection", + data_schema=vol.Schema( + { + vol.Required(CONF_STATION): vol.In(stations), + } + ), + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a reauth flow when API key is invalid/expired.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm a new API key.""" + errors: dict[str, str] = {} + + if user_input is not None: + api = Braendstofpriser(user_input[CONF_API_KEY]) + try: + await api.list_companies() + except ClientResponseError as exc: + errors["base"] = _get_api_error_key(exc) + + if not errors: + entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + if entry is not None: + self.hass.config_entries.async_update_entry( + entry, + data={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + +class FuelpricesDkStationSubentryFlow(ConfigSubentryFlow): + """Handle station subentries for Fuelprices.dk.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.api: Braendstofpriser + self.companies: list[dict[str, Any]] = [] + self.stations: Any = {} + self.company_name = "" + self._errors: dict[str, str] = {} + self.user_input: dict[str, Any] = {} + + async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]: + """Fetch stations for a company.""" + try: + stations = await self.api.list_stations(company_name=company_name) + except ClientResponseError as exc: + return None, _get_api_error_key(exc) + + if not stations: + return None, "cannot_connect" + + return stations, None + + def _show_company_selection_form( + self, errors: dict[str, str] | None = None + ) -> SubentryFlowResult: + """Show the company selection form.""" + default_company = self.user_input.get(CONF_COMPANY) + company_field = ( + vol.Required(CONF_COMPANY, default=default_company) + if default_company + else vol.Required(CONF_COMPANY) + ) + + return self.async_show_form( + step_id="company_selection", + data_schema=vol.Schema( + { + company_field: vol.In([c["company"] for c in self.companies]), + } + ), + errors=errors or {}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the initial step for adding a station subentry.""" + await self._async_init_api() + return await self.async_step_company_selection(user_input) + + async def _async_init_api(self) -> None: + """Initialize API client and fetch companies.""" + entry = self._get_entry() + api_key = entry.data[CONF_API_KEY] + self.api = Braendstofpriser(api_key) + try: + self.companies = await self.api.list_companies() + except ClientResponseError as exc: + self._errors["base"] = _get_api_error_key(exc) + return + + if not self.companies: + self._errors["base"] = "cannot_connect" + + async def async_step_company_selection( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the company selection step.""" + if self._errors: + return self.async_abort(reason=self._errors["base"]) + + if user_input is not None: + self.company_name = user_input[CONF_COMPANY] + self.user_input.update(user_input) + self.stations = {} + return await self.async_step_station_selection() + + return self._show_company_selection_form() + + async def async_step_station_selection( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the station selection step.""" + if not self.stations: + stations, error = await self._async_fetch_stations(self.company_name) + if error is not None: + self.user_input[CONF_COMPANY] = self.company_name + return self._show_company_selection_form({"base": error}) + self.stations = stations + + if user_input is not None: + user_input[CONF_STATION] = self.stations.find( + "name", user_input[CONF_STATION] + ) + + # Set UniqueID and abort if already existing + unique_id = ( + f"{self.user_input[CONF_COMPANY]}_{user_input[CONF_STATION]['id']}" + ) + entry = self._get_entry() + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="station_already_configured") + + # Process the user input and show next selection form + self.user_input.update(user_input) + return await self._async_create_or_update_subentry() + + stations = [s["name"] for s in self.stations] + + return self.async_show_form( + step_id="station_selection", + data_schema=vol.Schema( + { + vol.Required(CONF_STATION): vol.In(stations), + } + ), + errors=self._errors, + ) + + async def _async_create_or_update_subentry(self) -> SubentryFlowResult: + """Create the station subentry.""" + subentry_data = { + CONF_COMPANY: self.user_input[CONF_COMPANY], + CONF_STATION: self.user_input[CONF_STATION], + } + unique_id = ( + f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}" + ) + title = ( + f"{self.user_input[CONF_COMPANY]} - {self.user_input[CONF_STATION]['name']}" + ) + + entry = self._get_entry() + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_create_entry( + title=title, + data=subentry_data, + unique_id=unique_id, + ) diff --git a/homeassistant/components/fuelprices_dk/const.py b/homeassistant/components/fuelprices_dk/const.py new file mode 100644 index 000000000000..654754113558 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/const.py @@ -0,0 +1,10 @@ +"""Constants for the Fuelprices.dk integration.""" + +DOMAIN = "fuelprices_dk" + +CONF_COMPANY = "company" +CONF_STATION = "station" + +SUBENTRY_TYPE_STATION = "station" + +WEBSITE_URL = "https://fuelprices.dk" diff --git a/homeassistant/components/fuelprices_dk/coordinator.py b/homeassistant/components/fuelprices_dk/coordinator.py new file mode 100644 index 000000000000..11ccdf84eada --- /dev/null +++ b/homeassistant/components/fuelprices_dk/coordinator.py @@ -0,0 +1,64 @@ +"""Coordinator for the Fuelprices.dk integration.""" + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any, override + +from aiohttp import ClientResponseError +from pybraendstofpriser import Braendstofpriser +from pybraendstofpriser.exceptions import ProductNotFoundError + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +if TYPE_CHECKING: + from . import FuelpricesDkConfigEntry + +SCAN_INTERVAL = timedelta(hours=1) + +_LOGGER = logging.getLogger(__name__) + + +class FuelPricesDKCoordinator(DataUpdateCoordinator[dict[str, float | None]]): + """Data update coordinator for the Fuelprices.dk integration.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + company: str, + station: dict[str, Any], + subentry_id: str, + config_entry: FuelpricesDkConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + name=company, + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + self._api = Braendstofpriser(api_key) + self.company = company + self.station_id: int = station["id"] + self.station_name: str = station["name"] + self.subentry_id = subentry_id + + @override + async def _async_update_data(self) -> dict[str, float | None]: + """Handle data update request from the coordinator.""" + try: + data = await self._api.get_prices(self.station_id) + except ProductNotFoundError as exc: + raise ConfigEntryError(exc) from exc + except ClientResponseError as exc: + if exc.status == 401: + raise ConfigEntryAuthFailed(exc) from exc + raise ConfigEntryError(exc) from exc + + self.station_name = data["station"]["name"] + + return dict(data["prices"]) diff --git a/homeassistant/components/fuelprices_dk/manifest.json b/homeassistant/components/fuelprices_dk/manifest.json new file mode 100644 index 000000000000..5ffa98f952cc --- /dev/null +++ b/homeassistant/components/fuelprices_dk/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "fuelprices_dk", + "name": "Fuelprices.dk", + "codeowners": ["@MTrab"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fuelprices_dk", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pybraendstofpriser==2.2.0"] +} diff --git a/homeassistant/components/fuelprices_dk/quality_scale.yaml b/homeassistant/components/fuelprices_dk/quality_scale.yaml new file mode 100644 index 000000000000..d79be918fca4 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-conditions: + status: exempt + comment: | + The integration does not provide any additional conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: | + The integration does not provide any additional triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it connects to a cloud service. + discovery: + status: exempt + comment: | + This integration cannot be discovered, it connects to a cloud service. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: + status: exempt + comment: | + The integration does not provide any additional icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/fuelprices_dk/sensor.py b/homeassistant/components/fuelprices_dk/sensor.py new file mode 100644 index 000000000000..459183cca682 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/sensor.py @@ -0,0 +1,103 @@ +"""Sensor platform for the Fuelprices.dk integration.""" + +from typing import TYPE_CHECKING, override + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify as util_slugify + +from .const import DOMAIN +from .coordinator import FuelPricesDKCoordinator + +if TYPE_CHECKING: + from . import FuelpricesDkConfigEntry + +SENSORS = [ + SensorEntityDescription( + key="price", + name="Fuel Price", + native_unit_of_measurement="DKK/L", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:gas-station", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FuelpricesDkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform for Fuelprices.dk.""" + + for coordinator in entry.runtime_data.values(): + async_add_entities( + ( + FuelpricesDkSensor( + coordinator, + coordinator.station_name, + product_key, + sensor, + ) + for sensor in SENSORS + for product_key in coordinator.data + ), + config_subentry_id=coordinator.subentry_id, + ) + + +class FuelpricesDkSensor(CoordinatorEntity[FuelPricesDKCoordinator], RestoreSensor): + """Sensor for Fuelprices.dk.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FuelPricesDKCoordinator, + station_name: str, + product_key: str, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + + self._product_key = product_key + self._station_name = station_name + + self._attr_name = product_key + + self._attr_unique_id = util_slugify( + f"{self.coordinator.station_id}_{self.entity_description.key}_{product_key}" + ) + self._attr_config_subentry_id = self.coordinator.subentry_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(self.coordinator.station_id))}, + entry_type=DeviceEntryType.SERVICE, + name=self._station_name, + manufacturer=self.coordinator.company, + model=self.coordinator.station_name, + ) + + @property + @override + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self._product_key in self.coordinator.data + + @property + @override + def native_value(self) -> float | None: + """Return the current value of the sensor.""" + price = self.coordinator.data[self._product_key] + if isinstance(price, int | float): + return float(price) + return None diff --git a/homeassistant/components/fuelprices_dk/strings.json b/homeassistant/components/fuelprices_dk/strings.json new file mode 100644 index 000000000000..ad8337531f3f --- /dev/null +++ b/homeassistant/components/fuelprices_dk/strings.json @@ -0,0 +1,94 @@ +{ + "config": { + "abort": { + "already_configured": "This API key is already configured.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "Invalid API key provided", + "rate_limit_exceeded": "Too many requests to the API. Please try again later.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "station_already_configured": "The selected station for this company is already configured" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]", + "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]" + }, + "step": { + "company_selection": { + "data": { + "company": "Select company" + }, + "data_description": { + "company": "The company you want to fetch prices from" + }, + "description": "Select company" + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your personal API key" + }, + "description": "Your API key needs to be updated." + }, + "station_selection": { + "data": { + "station": "Select station" + }, + "data_description": { + "station": "The station you want to create sensors for" + }, + "description": "Select station to show prices for" + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your personal API key" + }, + "description": "Enter your Fuelprices.dk API key\nIf you do not have an API key, you can get one for free at {website_url}" + } + } + }, + "config_subentries": { + "station": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]", + "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]", + "station_already_configured": "[%key:component::fuelprices_dk::config::abort::station_already_configured%]" + }, + "entry_type": "Station", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]", + "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]" + }, + "initiate_flow": { + "user": "Add station" + }, + "step": { + "company_selection": { + "data": { + "company": "[%key:component::fuelprices_dk::config::step::company_selection::data::company%]" + }, + "data_description": { + "company": "[%key:component::fuelprices_dk::config::step::company_selection::data_description::company%]" + }, + "description": "[%key:component::fuelprices_dk::config::step::company_selection::description%]" + }, + "station_selection": { + "data": { + "station": "[%key:component::fuelprices_dk::config::step::station_selection::data::station%]" + }, + "data_description": { + "station": "[%key:component::fuelprices_dk::config::step::station_selection::data_description::station%]" + }, + "description": "[%key:component::fuelprices_dk::config::step::station_selection::description%]" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0d2fea79361d..00f5983f6f73 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -257,6 +257,7 @@ FLOWS = { "fritzbox_callmonitor", "fronius", "frontier_silicon", + "fuelprices_dk", "fujitsu_fglair", "fully_kiosk", "fumis", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6459b2ea5ca1..95eedf8ef476 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2319,6 +2319,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "fuelprices_dk": { + "name": "Fuelprices.dk", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "fujitsu": { "name": "Fujitsu", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index 37b079fcb578..8ce85d4f3b6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2063,6 +2063,9 @@ pyblu==2.0.8 # homeassistant.components.neato pybotvac==0.0.29 +# homeassistant.components.fuelprices_dk +pybraendstofpriser==2.2.0 + # homeassistant.components.braviatv pybravia==0.4.1 diff --git a/tests/components/fuelprices_dk/__init__.py b/tests/components/fuelprices_dk/__init__.py new file mode 100644 index 000000000000..acdada59323d --- /dev/null +++ b/tests/components/fuelprices_dk/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Danish Fuelprices integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration from a mock config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/fuelprices_dk/conftest.py b/tests/components/fuelprices_dk/conftest.py new file mode 100644 index 000000000000..cf7bd2034dd9 --- /dev/null +++ b/tests/components/fuelprices_dk/conftest.py @@ -0,0 +1,81 @@ +"""Common fixtures for Fuelprices.dk tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pybraendstofpriser import Flist +import pytest + +from homeassistant.components.fuelprices_dk.const import ( + CONF_COMPANY, + CONF_STATION, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + +TEST_API_KEY = "test-api-key" +TEST_COMPANY = "Circle K" +TEST_STATION = {"id": 1234, "name": "Aarhus C"} +TEST_PRICES = {"Blyfri95": 14.29, "Diesel": 12.99, "Blyfri98": 14.99} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry for config flow tests.""" + with patch( + "homeassistant.components.fuelprices_dk.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a standard mock config entry with one station subentry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Fuelprices.dk", + version=1, + data={CONF_API_KEY: TEST_API_KEY}, + subentries_data=[ + ConfigSubentryData( + subentry_type="station", + title=f"{TEST_COMPANY} - {TEST_STATION['name']}", + unique_id=f"{TEST_COMPANY}_{TEST_STATION['id']}", + data={ + CONF_COMPANY: TEST_COMPANY, + CONF_STATION: TEST_STATION, + }, + ) + ], + ) + + +@pytest.fixture +def mock_braendstofpriser() -> Generator[AsyncMock]: + """Mock the pybraendstofpriser client used by the integration.""" + with ( + patch( + "homeassistant.components.fuelprices_dk.config_flow.Braendstofpriser", + autospec=True, + ) as mock_config_flow_client, + patch( + "homeassistant.components.fuelprices_dk.coordinator.Braendstofpriser", + new=mock_config_flow_client, + ), + ): + client = mock_config_flow_client.return_value + client.list_companies.return_value = [{"company": TEST_COMPANY}] + client.list_stations.return_value = Flist([TEST_STATION]) + client.get_prices.return_value = { + "station": { + "id": TEST_STATION["id"], + "name": TEST_STATION["name"], + "last_update": "2024-01-01T12:00:00", + }, + "prices": TEST_PRICES, + } + yield client diff --git a/tests/components/fuelprices_dk/snapshots/test_sensor.ambr b/tests/components/fuelprices_dk/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..119b9355bca0 --- /dev/null +++ b/tests/components/fuelprices_dk/snapshots/test_sensor.ambr @@ -0,0 +1,166 @@ +# serializer version: 1 +# name: test_sensors[sensor.aarhus_c_blyfri95-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aarhus_c_blyfri95', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blyfri95', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gas-station', + 'original_name': 'Blyfri95', + 'platform': 'fuelprices_dk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_price_blyfri95', + 'unit_of_measurement': 'DKK/L', + }) +# --- +# name: test_sensors[sensor.aarhus_c_blyfri95-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aarhus C Blyfri95', + : 'mdi:gas-station', + : , + : 'DKK/L', + }), + 'context': , + 'entity_id': 'sensor.aarhus_c_blyfri95', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.29', + }) +# --- +# name: test_sensors[sensor.aarhus_c_blyfri98-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aarhus_c_blyfri98', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blyfri98', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gas-station', + 'original_name': 'Blyfri98', + 'platform': 'fuelprices_dk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_price_blyfri98', + 'unit_of_measurement': 'DKK/L', + }) +# --- +# name: test_sensors[sensor.aarhus_c_blyfri98-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aarhus C Blyfri98', + : 'mdi:gas-station', + : , + : 'DKK/L', + }), + 'context': , + 'entity_id': 'sensor.aarhus_c_blyfri98', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.99', + }) +# --- +# name: test_sensors[sensor.aarhus_c_diesel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aarhus_c_diesel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Diesel', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gas-station', + 'original_name': 'Diesel', + 'platform': 'fuelprices_dk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_price_diesel', + 'unit_of_measurement': 'DKK/L', + }) +# --- +# name: test_sensors[sensor.aarhus_c_diesel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aarhus C Diesel', + : 'mdi:gas-station', + : , + : 'DKK/L', + }), + 'context': , + 'entity_id': 'sensor.aarhus_c_diesel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.99', + }) +# --- diff --git a/tests/components/fuelprices_dk/test_config_flow.py b/tests/components/fuelprices_dk/test_config_flow.py new file mode 100644 index 000000000000..6c2407ce1345 --- /dev/null +++ b/tests/components/fuelprices_dk/test_config_flow.py @@ -0,0 +1,447 @@ +"""Test the Fuelprices.dk config flow.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientResponseError +from pybraendstofpriser import Flist +import pytest + +from homeassistant.components.fuelprices_dk.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .conftest import TEST_API_KEY, TEST_COMPANY, TEST_STATION + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +def _client_error(status: int) -> ClientResponseError: + """Create an aiohttp client response error with a specific status code.""" + return ClientResponseError( + request_info=Mock(), + history=(), + status=status, + message="error", + headers=None, + ) + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test a full successful config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "station_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Fuelprices.dk" + assert result["data"] == {CONF_API_KEY: TEST_API_KEY} + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == "station" + assert subentry["title"] == f"{TEST_COMPANY} - {TEST_STATION['name']}" + assert subentry["unique_id"] == f"{TEST_COMPANY}_{TEST_STATION['id']}" + assert subentry["data"] == {"company": TEST_COMPANY, "station": TEST_STATION} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("status", "error"), + [ + (401, "invalid_api_key"), + (429, "rate_limit_exceeded"), + (500, "cannot_connect"), + ], +) +async def test_user_flow_recovers_from_api_errors( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + status: int, + error: str, +) -> None: + """Test the user flow shows an error and then recovers.""" + mock_braendstofpriser.list_companies.side_effect = _client_error(status) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_braendstofpriser.list_companies.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_recovers_without_companies( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, +) -> None: + """Test the user flow recovers when the API returns no companies.""" + mock_braendstofpriser.list_companies.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + mock_braendstofpriser.list_companies.return_value = [{"company": TEST_COMPANY}] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_duplicate_api_key_aborts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test flow aborts when the same API key is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "configure_stations", + [ + lambda mock: setattr(mock.list_stations, "side_effect", _client_error(500)), + lambda mock: setattr(mock.list_stations, "return_value", Flist([])), + ], + ids=["error", "empty"], +) +async def test_user_flow_station_error_returns_to_company_selection( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + configure_stations: Callable[[AsyncMock], None], +) -> None: + """Test station loading errors return the user to company selection.""" + configure_stations(mock_braendstofpriser) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + assert result["errors"] == {"base": "cannot_connect"} + + mock_braendstofpriser.list_stations.side_effect = None + mock_braendstofpriser.list_stations.return_value = Flist([TEST_STATION]) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_allows_different_api_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_braendstofpriser: AsyncMock, +) -> None: + """Test flow allows a different API key.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "other-api-key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "other-api-key"} + + +async def test_reauth_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_braendstofpriser: AsyncMock, +) -> None: + """Test reauthentication updates the API key.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "new-api-key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + + +@pytest.mark.parametrize( + ("status", "error"), + [ + (401, "invalid_api_key"), + (429, "rate_limit_exceeded"), + (500, "cannot_connect"), + ], +) +async def test_reauth_recovers_from_api_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_braendstofpriser: AsyncMock, + status: int, + error: str, +) -> None: + """Test reauth shows an error and then recovers.""" + mock_config_entry.add_to_hass(hass) + mock_braendstofpriser.list_companies.side_effect = _client_error(status) + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bad-key"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_braendstofpriser.list_companies.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "new-api-key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_subentry_flow_create( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a station subentry.""" + await setup_integration(hass, mock_config_entry) + + new_station = {"id": 4321, "name": "Aarhus N"} + mock_braendstofpriser.list_stations.return_value = Flist( + [TEST_STATION, new_station] + ) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "station_selection" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"station": new_station["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"{TEST_COMPANY} - {new_station['name']}" + assert result["unique_id"] == f"{TEST_COMPANY}_{new_station['id']}" + + +async def test_subentry_flow_duplicate_station( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry flow aborts for an already configured station.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "station_already_configured" + + +@pytest.mark.parametrize( + ("status", "reason"), + [ + (401, "invalid_api_key"), + (429, "rate_limit_exceeded"), + (500, "cannot_connect"), + ], +) +async def test_subentry_flow_api_init_error( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + status: int, + reason: str, +) -> None: + """Test subentry flow aborts for API init errors.""" + await setup_integration(hass, mock_config_entry) + mock_braendstofpriser.list_companies.side_effect = _client_error(status) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_subentry_flow_no_companies_aborts( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry flow aborts when no companies are returned.""" + await setup_integration(hass, mock_config_entry) + mock_braendstofpriser.list_companies.return_value = [] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + "configure_stations", + [ + lambda mock: setattr(mock.list_stations, "side_effect", _client_error(500)), + lambda mock: setattr(mock.list_stations, "return_value", Flist([])), + ], + ids=["error", "empty"], +) +async def test_subentry_flow_station_error_returns_to_company_selection( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + configure_stations: Callable[[AsyncMock], None], +) -> None: + """Test station loading errors return the user to company selection.""" + await setup_integration(hass, mock_config_entry) + + configure_stations(mock_braendstofpriser) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + assert result["errors"] == {"base": "cannot_connect"} + + new_station = {"id": 4321, "name": "Aarhus N"} + mock_braendstofpriser.list_stations.side_effect = None + mock_braendstofpriser.list_stations.return_value = Flist( + [TEST_STATION, new_station] + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"station": new_station["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/fuelprices_dk/test_init.py b/tests/components/fuelprices_dk/test_init.py new file mode 100644 index 000000000000..70098a5fb720 --- /dev/null +++ b/tests/components/fuelprices_dk/test_init.py @@ -0,0 +1,147 @@ +"""Test initialization for Fuelprices.dk.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientResponseError +from pybraendstofpriser import Flist +from pybraendstofpriser.exceptions import ProductNotFoundError +import pytest + +from homeassistant.config_entries import ( + ConfigEntryState, + ConfigSubentry, + ConfigSubentryData, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import TEST_API_KEY, TEST_COMPANY, TEST_PRICES, TEST_STATION + +from tests.common import MockConfigEntry + + +def _client_error(status: int) -> ClientResponseError: + """Create an aiohttp client response error with a specific status code.""" + return ClientResponseError( + request_info=Mock(), + history=(), + status=status, + message="error", + headers=None, + ) + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the config entry is set up and unloaded correctly.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_reload_on_subentry_added( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the entry reloads and adds entities when a subentry is added.""" + await setup_integration(hass, mock_config_entry) + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == len(TEST_PRICES) + + mock_braendstofpriser.get_prices.reset_mock() + + new_station = {"id": 4321, "name": "Aarhus N"} + hass.config_entries.async_add_subentry( + mock_config_entry, + ConfigSubentry( + subentry_type="station", + title=f"{TEST_COMPANY} - {new_station['name']}", + unique_id=f"{TEST_COMPANY}_{new_station['id']}", + data={"company": TEST_COMPANY, "station": new_station}, + ), + ) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == len(TEST_PRICES) * 2 + assert mock_braendstofpriser.get_prices.await_count == 2 + + +async def test_skips_non_station_subentries( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup skips unsupported subentry types.""" + config_entry = MockConfigEntry( + domain="fuelprices_dk", + version=1, + data={CONF_API_KEY: TEST_API_KEY}, + subentries_data=[ + ConfigSubentryData( + subentry_type="other", + title="Other", + unique_id="other_1", + data={"company": TEST_COMPANY, "station": TEST_STATION}, + ) + ], + ) + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + mock_braendstofpriser.get_prices.assert_not_called() + + +async def test_stations_use_flist( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup completes when the API returns an Flist of stations.""" + mock_braendstofpriser.list_stations.return_value = Flist([TEST_STATION]) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (_client_error(401), ConfigEntryState.SETUP_ERROR), + (_client_error(500), ConfigEntryState.SETUP_ERROR), + (ProductNotFoundError("missing"), ConfigEntryState.SETUP_ERROR), + ], + ids=["auth_failed", "cannot_connect", "product_not_found"], +) +async def test_setup_error_handling( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup handles API errors during the first refresh.""" + mock_braendstofpriser.get_prices.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state diff --git a/tests/components/fuelprices_dk/test_sensor.py b/tests/components/fuelprices_dk/test_sensor.py new file mode 100644 index 000000000000..fb84bf14a797 --- /dev/null +++ b/tests/components/fuelprices_dk/test_sensor.py @@ -0,0 +1,97 @@ +"""Test sensor platform for Fuelprices.dk.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import TEST_PRICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.fuelprices_dk.PLATFORMS", ["sensor"]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_updates( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor updates when the coordinator refreshes.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == "14.29" + + mock_braendstofpriser.get_prices.return_value = { + "station": {"id": 1234, "name": "Aarhus C", "last_update": None}, + "prices": {**TEST_PRICES, "Blyfri95": 15.99}, + } + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == "15.99" + + +async def test_sensor_becomes_unavailable_when_product_missing( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a sensor becomes unavailable when its product is not returned.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == "14.29" + + remaining = {k: v for k, v in TEST_PRICES.items() if k != "Blyfri95"} + mock_braendstofpriser.get_prices.return_value = { + "station": {"id": 1234, "name": "Aarhus C", "last_update": None}, + "prices": remaining, + } + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == STATE_UNAVAILABLE + + +async def test_sensor_ignores_non_numeric_price( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a sensor reports unknown when the API returns a non-numeric price.""" + await setup_integration(hass, mock_config_entry) + + mock_braendstofpriser.get_prices.return_value = { + "station": {"id": 1234, "name": "Aarhus C", "last_update": None}, + "prices": {**TEST_PRICES, "Blyfri95": "n/a"}, + } + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == STATE_UNKNOWN From df974fff3eaba6df627fe95bb273c69722742485 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:08:41 +0200 Subject: [PATCH 456/707] Migrate UniFi Protect camera microphone volume to the public API (#174964) --- .../components/unifiprotect/number.py | 3 +- tests/components/unifiprotect/test_number.py | 57 +++++++++++++++++++ tests/components/unifiprotect/utils.py | 12 ++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 276cb61ec3e5..fd95c888b16e 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -90,8 +90,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_max=100, ufp_step=1, ufp_required_field="has_mic", - ufp_value="mic_volume", - ufp_enabled="feature_flags.has_mic", + ufp_public_value="mic_volume", ufp_set_method="set_mic_volume_public", ufp_perm=PermRequired.WRITE, ), diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 56b595871ae1..ed915a9559de 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -38,10 +38,12 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_camera, make_public_light, make_public_sensor, public_device_ws_message, remove_entities, + setup_public_camera, setup_public_light, setup_public_sensor, ) @@ -117,6 +119,7 @@ async def test_number_setup_camera_all( camera.speaker_settings.volume = 1 camera.feature_flags.is_doorbell = True camera.speaker_settings.ring_volume = 1 + setup_public_camera(ufp) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.NUMBER, 7, 7) @@ -298,6 +301,7 @@ async def test_number_camera_simple( description: ProtectNumberEntityDescription, ) -> None: """Tests simple numbers for cameras using the all features fixture.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [camera_all_features]) assert_entity_counts(hass, Platform.NUMBER, 7, 7) @@ -320,6 +324,59 @@ async def test_number_camera_simple( mock_method.assert_called_once_with(1.0) +async def test_number_camera_mic_volume_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +) -> None: + """Mic volume reads from the public object and refreshes on a public WS update.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [camera]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, CAMERA_NUMBERS[1] + ) + + # A public value the private fixture (1) would not produce proves the source. + public = make_public_camera(camera, mic_volume=42) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "42" + + +async def test_number_camera_mic_volume_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +) -> None: + """The migrated mic volume number is unavailable without a public object.""" + + await init_entry(hass, ufp, [camera]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, CAMERA_NUMBERS[1] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_number_camera_mic_volume_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +) -> None: + """Mic volume availability follows the public object's connection state.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [camera]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, CAMERA_NUMBERS[1] + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_camera(camera, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_number_sense_sensitivity_public_value( hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ) -> None: diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 6560f66b04b4..eb6e47b4bbe7 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -356,19 +356,21 @@ def make_public_camera( camera: Camera, *, state: DeviceState | None = None, + mic_volume: int | None = None, hdr_type: PublicHdrMode | None = None, ) -> Mock: """Build a public-API camera mirroring a private camera's migrated fields. - ``hdr_type`` defaults to the public mode derived from the private - ``hdr_mode_display`` so the migrated HDR select reads the same value the - private object would produce; pass it to diverge from that. + ``mic_volume`` and ``hdr_type`` default to values derived from the private + fixture so the public mirror matches it; pass an override to assert a value + the private object would not produce. """ public = Mock(spec=PublicCamera) public.id = camera.id public.mac = camera.mac public.model = ModelType.CAMERA public.state = DeviceState[camera.state.name] if state is None else state + public.mic_volume = camera.mic_volume if mic_volume is None else mic_volume public.hdr_type = ( _HDR_DISPLAY_TO_PUBLIC[camera.hdr_mode_display] if hdr_type is None @@ -441,8 +443,8 @@ def setup_public_light(ufp: MockUFPFixture) -> None: def setup_public_camera(ufp: MockUFPFixture) -> None: """Expose private cameras over the public API via a real ``PublicBootstrap``. - Mirrors ``setup_public_sensor`` for ``ModelType.CAMERA`` so the migrated HDR - select reads from the public object. + Mirrors ``setup_public_sensor`` for ``ModelType.CAMERA`` so the migrated + camera config entities read from the public object. """ public_bootstrap = PublicBootstrap() pb = Mock(spec=PublicBootstrap) From 90dbf1a7a973c1817256fa36ea08149265ada188 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Jul 2026 21:23:04 +0200 Subject: [PATCH 457/707] Bump pystiebeleltron to 0.5.0 (#176166) Co-authored-by: Claude --- .../components/stiebel_eltron/__init__.py | 31 +++--- .../components/stiebel_eltron/climate.py | 8 +- .../components/stiebel_eltron/config_flow.py | 12 ++- .../components/stiebel_eltron/const.py | 1 + .../components/stiebel_eltron/coordinator.py | 24 ++--- .../components/stiebel_eltron/diagnostics.py | 3 - .../components/stiebel_eltron/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/stiebel_eltron/conftest.py | 46 ++++----- .../snapshots/test_diagnostics.ambr | 3 - .../components/stiebel_eltron/test_climate.py | 8 +- .../stiebel_eltron/test_config_flow.py | 21 ++++- tests/components/stiebel_eltron/test_init.py | 94 +++++++++++++------ 13 files changed, 153 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 187a7ae674af..41933a733e42 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -2,14 +2,15 @@ import logging -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusError +from modbus_connection.pymodbus import connect_tcp from pystiebeleltron import StiebelEltronModbusError, get_controller_model from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady -from .const import DEFAULT_PORT +from .const import DEFAULT_PORT, UNIT_ID from .coordinator import StiebelEltronConfigEntry, StiebelEltronDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -25,17 +26,27 @@ async def async_setup_entry( port = entry.data.get(CONF_PORT, DEFAULT_PORT) try: - model = await get_controller_model(host, port) - except ModbusException as exception: + connection = await connect_tcp(host, port=port) + except ModbusError as exception: raise ConfigEntryNotReady("Could not connect to device") from exception - except StiebelEltronModbusError as exception: - raise ConfigEntryError(exception) from exception + entry.async_on_unload(connection.close) - coordinator = StiebelEltronDataCoordinator(hass, entry, model, host, port) + try: + model = await get_controller_model(connection.for_unit(UNIT_ID)) + except StiebelEltronModbusError as exception: + raise ConfigEntryNotReady("Could not read controller model") from exception + + coordinator = StiebelEltronDataCoordinator(hass, entry, model, connection, host) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() + entry.async_on_unload( + connection.on_connection_lost( + lambda: hass.config_entries.async_schedule_reload(entry.entry_id) + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -45,6 +56,4 @@ async def async_unload_entry( entry: StiebelEltronConfigEntry, ) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, _PLATFORMS): - await entry.runtime_data.close() - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 493b0818db55..1833ffc8d8b0 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -3,7 +3,7 @@ import logging from typing import Any, override -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusError from pystiebeleltron.lwz import OperatingMode from homeassistant.components.climate import ( @@ -135,7 +135,7 @@ class StiebelEltron(StiebelEltronEntity, ClimateEntity): _LOGGER.debug("async_set_hvac_mode: %s -> %s", self._attr_hvac_mode, new_mode) try: await self.coordinator.api_client.set_operation(new_mode) - except ModbusException as e: + except ModbusError as e: _LOGGER.error("Error setting HVAC mode: %s", e) raise HomeAssistantError("Failed to set HVAC mode") from e await self.coordinator.async_request_refresh() @@ -147,7 +147,7 @@ class StiebelEltron(StiebelEltronEntity, ClimateEntity): _LOGGER.debug("async_set_temperature: %s", target_temperature) try: await self.coordinator.api_client.set_target_temp(target_temperature) - except ModbusException as e: + except ModbusError as e: _LOGGER.error("Error setting target temperature: %s", e) raise HomeAssistantError("Failed to set target temperature") from e await self.coordinator.async_request_refresh() @@ -161,7 +161,7 @@ class StiebelEltron(StiebelEltronEntity, ClimateEntity): ) try: await self.coordinator.api_client.set_operation(new_preset) - except ModbusException as e: + except ModbusError as e: _LOGGER.error("Error setting preset mode: %s", e) raise HomeAssistantError("Failed to set preset mode") from e await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py index 04fe34dcde32..0bbe59b0a5ee 100644 --- a/homeassistant/components/stiebel_eltron/config_flow.py +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -3,6 +3,8 @@ import logging from typing import Any, override +from modbus_connection import ModbusError +from modbus_connection.pymodbus import connect_tcp from pystiebeleltron import StiebelEltronModbusError, get_controller_model import voluptuous as vol @@ -17,7 +19,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DEFAULT_PORT, DOMAIN +from .const import DEFAULT_PORT, DOMAIN, UNIT_ID _LOGGER = logging.getLogger(__name__) @@ -37,8 +39,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( async def check_controller_model(host: str, port: int) -> str | None: """Check if the controller model is valid.""" try: - await get_controller_model(host, port) - except StiebelEltronModbusError: + connection = await connect_tcp(host, port=port) + try: + await get_controller_model(connection.for_unit(UNIT_ID)) + finally: + await connection.close() + except StiebelEltronModbusError, ModbusError: _LOGGER.debug("Cannot connect to Stiebel Eltron device", exc_info=True) return "cannot_connect" except Exception: diff --git a/homeassistant/components/stiebel_eltron/const.py b/homeassistant/components/stiebel_eltron/const.py index 6b6965dd244e..9522a541c31d 100644 --- a/homeassistant/components/stiebel_eltron/const.py +++ b/homeassistant/components/stiebel_eltron/const.py @@ -5,3 +5,4 @@ DOMAIN = "stiebel_eltron" DEFAULT_PORT = 502 DEFAULT_SCAN_INTERVAL = 30 +UNIT_ID = 1 diff --git a/homeassistant/components/stiebel_eltron/coordinator.py b/homeassistant/components/stiebel_eltron/coordinator.py index af5bf3cc2a72..8e1f6dde65b4 100644 --- a/homeassistant/components/stiebel_eltron/coordinator.py +++ b/homeassistant/components/stiebel_eltron/coordinator.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging from typing import override -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusConnection, ModbusError from pystiebeleltron import ControllerModel from pystiebeleltron.lwz import LwzStiebelEltronAPI @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN, UNIT_ID _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -28,8 +28,8 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]): hass: HomeAssistant, entry: StiebelEltronConfigEntry, model: ControllerModel, + connection: ModbusConnection, host: str, - port: int, ) -> None: """Initialize the StiebelEltronDataCoordinator.""" super().__init__( @@ -42,32 +42,20 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]): # the register values), so there is nothing to diff against. always_update=True, ) - self.api_client = LwzStiebelEltronAPI(host=host, port=port) + self.api_client = LwzStiebelEltronAPI(connection.for_unit(UNIT_ID)) self.device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, - configuration_url=f"http://{self.host}", + configuration_url=f"http://{host}", name=self.name, model=model.name, model_id=str(model.value), manufacturer=ATTR_MANUFACTURER, ) - async def close(self) -> None: - """Disconnect client.""" - _LOGGER.debug("Closing connection to %s", self.host) - await self.api_client.close() - - @property - def host(self) -> str: - """Return the host address of the Stiebel Eltron ISG.""" - return self.api_client.host - @override async def _async_update_data(self) -> None: """Fetch the latest data from the source.""" try: - if not self.api_client.is_connected: - await self.api_client.connect() await self.api_client.async_update() - except ModbusException as exception: + except ModbusError as exception: raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/stiebel_eltron/diagnostics.py b/homeassistant/components/stiebel_eltron/diagnostics.py index 483968dca5c6..5267286d830c 100644 --- a/homeassistant/components/stiebel_eltron/diagnostics.py +++ b/homeassistant/components/stiebel_eltron/diagnostics.py @@ -20,9 +20,6 @@ async def async_get_config_entry_diagnostics( return { "entry_data": async_redact_data(entry.data, TO_REDACT), "model": coordinator.device_info["model"], - "modbus": { - "is_connected": coordinator.api_client.is_connected, - }, "data": { "current_temp": coordinator.api_client.get_current_temp(), "target_temp": coordinator.api_client.get_target_temp(), diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index 13603b7f3500..f490c4622d66 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], "quality_scale": "silver", - "requirements": ["pystiebeleltron==0.2.5"] + "requirements": ["pystiebeleltron==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8ce85d4f3b6e..739c8423fc4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2604,7 +2604,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.14.0 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.2.5 +pystiebeleltron==0.5.0 # homeassistant.components.suez_water pysuezV2==2.0.7 diff --git a/tests/components/stiebel_eltron/conftest.py b/tests/components/stiebel_eltron/conftest.py index 489377acd783..395d8ed90947 100644 --- a/tests/components/stiebel_eltron/conftest.py +++ b/tests/components/stiebel_eltron/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch +from modbus_connection.mock import MockModbusConnection from pystiebeleltron import ControllerModel from pystiebeleltron.lwz import OperatingMode import pytest @@ -30,6 +31,22 @@ def mock_get_controller_model() -> Generator[MagicMock]: yield mock_get_model +@pytest.fixture(autouse=True) +def mock_connect_tcp( + mock_modbus_connection: MockModbusConnection, +) -> Generator[AsyncMock]: + """Patch connect_tcp to return the in-memory mock connection.""" + connect = AsyncMock(return_value=mock_modbus_connection) + with ( + patch("homeassistant.components.stiebel_eltron.connect_tcp", new=connect), + patch( + "homeassistant.components.stiebel_eltron.config_flow.connect_tcp", + new=connect, + ), + ): + yield connect + + @pytest.fixture(autouse=True) def mock_lwz_api() -> Generator[MagicMock]: """Patch the LWZ API and return the mocked client.""" @@ -38,29 +55,16 @@ def mock_lwz_api() -> Generator[MagicMock]: "homeassistant.components.stiebel_eltron.coordinator.LwzStiebelEltronAPI", autospec=True, ) as mock_api_cls: - api_client = MagicMock() + api_client = mock_api_cls.return_value - api_client.get_target_temp = MagicMock(return_value=22.5) - api_client.get_current_temp = MagicMock(return_value=21.0) - api_client.get_current_humidity = MagicMock(return_value=45.0) - api_client.get_operation = MagicMock(return_value=OperatingMode.AUTOMATIC) - api_client.get_heating_status = MagicMock(return_value=True) - api_client.get_cooling_status = MagicMock(return_value=False) - api_client.get_filter_alarm_status = MagicMock(return_value=False) + api_client.get_target_temp.return_value = 22.5 + api_client.get_current_temp.return_value = 21.0 + api_client.get_current_humidity.return_value = 45.0 + api_client.get_operation.return_value = OperatingMode.AUTOMATIC + api_client.get_heating_status.return_value = True + api_client.get_cooling_status.return_value = False + api_client.get_filter_alarm_status.return_value = False - def _connect() -> None: - api_client.is_connected = True - - api_client.connect = AsyncMock(side_effect=_connect) - api_client.close = AsyncMock() - api_client.async_update = AsyncMock() - api_client.set_operation = AsyncMock() - api_client.set_target_temp = AsyncMock() - - api_client.is_connected = False - api_client.host = "1.1.1.1" - - mock_api_cls.return_value = api_client yield api_client diff --git a/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr b/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr index 004cc7fa8435..d192b35094be 100644 --- a/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr +++ b/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr @@ -14,9 +14,6 @@ 'host': '**REDACTED**', 'port': 502, }), - 'modbus': dict({ - 'is_connected': True, - }), 'model': 'LWZ', }) # --- diff --git a/tests/components/stiebel_eltron/test_climate.py b/tests/components/stiebel_eltron/test_climate.py index 5ee6647a2ba0..77f4e6b86f6c 100644 --- a/tests/components/stiebel_eltron/test_climate.py +++ b/tests/components/stiebel_eltron/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusError from pystiebeleltron.lwz import OperatingMode import pytest from syrupy.assertion import SnapshotAssertion @@ -145,7 +145,7 @@ async def test_climate_entity_set_hvac_mode_handles_api_exception( """Test setting HVAC mode handles API exception.""" await _setup_integration(hass, mock_config_entry) - mock_lwz_api.set_operation.side_effect = ModbusException("write failed") + mock_lwz_api.set_operation.side_effect = ModbusError("write failed") with pytest.raises(HomeAssistantError): await async_set_hvac_mode(hass, HVACMode.AUTO, CLIMATE_ENTITY_ID) @@ -158,7 +158,7 @@ async def test_climate_entity_set_preset_mode_handles_api_exception( """Test setting preset mode handles API exception.""" await _setup_integration(hass, mock_config_entry) - mock_lwz_api.set_operation.side_effect = ModbusException("write failed") + mock_lwz_api.set_operation.side_effect = ModbusError("write failed") with pytest.raises(HomeAssistantError): await async_set_preset_mode(hass, PRESET_COMFORT, CLIMATE_ENTITY_ID) @@ -172,7 +172,7 @@ async def test_climate_entity_set_temperature_handles_api_exception( await _setup_integration(hass, mock_config_entry) - mock_lwz_api.set_target_temp.side_effect = ModbusException("write failed") + mock_lwz_api.set_target_temp.side_effect = ModbusError("write failed") with pytest.raises(HomeAssistantError): await async_set_temperature(hass, 24.0, CLIMATE_ENTITY_ID) diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py index 568d0e1897f5..b325d90380a3 100644 --- a/tests/components/stiebel_eltron/test_config_flow.py +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from modbus_connection import ModbusError from pystiebeleltron import ControllerModel, StiebelEltronModbusError import pytest @@ -41,16 +42,28 @@ async def test_full_flow(hass: HomeAssistant) -> None: assert result["data"] == USER_INPUT +@pytest.mark.parametrize( + ("failing_fixture", "side_effect"), + [ + pytest.param( + "mock_get_controller_model", StiebelEltronModbusError, id="model_read" + ), + pytest.param("mock_connect_tcp", ModbusError, id="connect"), + ], +) async def test_form_cannot_connect( hass: HomeAssistant, - mock_get_controller_model: MagicMock, + request: pytest.FixtureRequest, + failing_fixture: str, + side_effect: type[Exception], ) -> None: - """Test we handle cannot connect error.""" + """Test we handle a cannot connect error while opening or reading the device.""" + failing_mock = request.getfixturevalue(failing_fixture) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - mock_get_controller_model.side_effect = StiebelEltronModbusError + failing_mock.side_effect = side_effect result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -60,7 +73,7 @@ async def test_form_cannot_connect( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - mock_get_controller_model.side_effect = None + failing_mock.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py index c690ecb7a709..5f93cc84e25c 100644 --- a/tests/components/stiebel_eltron/test_init.py +++ b/tests/components/stiebel_eltron/test_init.py @@ -1,8 +1,9 @@ """Tests for the STIEBEL ELTRON integration.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusError, ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection from pystiebeleltron import StiebelEltronModbusError from homeassistant.components.stiebel_eltron.const import DOMAIN @@ -27,7 +28,7 @@ async def test_async_setup_entry_success( async def test_async_setup_entry_with_custom_port( hass: HomeAssistant, - mock_get_controller_model: MagicMock, + mock_connect_tcp: AsyncMock, ) -> None: """Test setup with custom port.""" config_entry = MockConfigEntry( @@ -40,12 +41,12 @@ async def test_async_setup_entry_with_custom_port( result = await hass.config_entries.async_setup(config_entry.entry_id) assert result is True - mock_get_controller_model.assert_called_once_with("192.168.1.100", 5020) + mock_connect_tcp.assert_called_once_with("192.168.1.100", port=5020) async def test_async_setup_entry_without_port( hass: HomeAssistant, - mock_get_controller_model: MagicMock, + mock_connect_tcp: AsyncMock, ) -> None: """Test setup without port (should use default).""" config_entry = MockConfigEntry( @@ -58,31 +59,16 @@ async def test_async_setup_entry_without_port( result = await hass.config_entries.async_setup(config_entry.entry_id) assert result is True - mock_get_controller_model.assert_called_once_with("192.168.1.100", 502) + mock_connect_tcp.assert_called_once_with("192.168.1.100", port=502) -async def test_async_setup_entry_modbus_error( +async def test_async_setup_entry_cannot_connect( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_get_controller_model: MagicMock, + mock_connect_tcp: AsyncMock, ) -> None: - """Test setup fails when get_controller_model raises an error.""" - mock_config_entry.add_to_hass(hass) - mock_get_controller_model.side_effect = StiebelEltronModbusError() - - result = await hass.config_entries.async_setup(mock_config_entry.entry_id) - - assert result is False - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - - -async def test_async_setup_entry_coordinator_update_fails( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_lwz_api: MagicMock, -) -> None: - """Test setup retries when coordinator data update raises ModbusException.""" - mock_lwz_api.async_update.side_effect = ModbusException("update failed") + """Test setup retries when the connection cannot be opened.""" + mock_connect_tcp.side_effect = ModbusTimeoutError("could not connect") mock_config_entry.add_to_hass(hass) result = await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -91,10 +77,60 @@ async def test_async_setup_entry_coordinator_update_fails( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry_closes_connection( +async def test_async_setup_entry_modbus_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_controller_model: MagicMock, +) -> None: + """Test setup retries when reading the controller model fails.""" + mock_config_entry.add_to_hass(hass) + mock_get_controller_model.side_effect = StiebelEltronModbusError() + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_async_setup_entry_coordinator_update_fails( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lwz_api: MagicMock, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Test setup retries and closes the connection when the first update fails.""" + mock_lwz_api.async_update.side_effect = ModbusError("update failed") + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_modbus_connection.connected is False + + +async def test_connection_lost_reloads_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Test a lost connection schedules a reload of the config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch.object( + hass.config_entries, "async_schedule_reload" + ) as mock_schedule_reload: + mock_modbus_connection.simulate_connection_lost() + + mock_schedule_reload.assert_called_once_with(mock_config_entry.entry_id) + + +async def test_unload_entry_closes_connection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, ) -> None: """Test unloading the config entry closes the Modbus connection.""" mock_config_entry.add_to_hass(hass) @@ -106,13 +142,13 @@ async def test_unload_entry_closes_connection( assert result is True assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - mock_lwz_api.close.assert_awaited_once() + assert mock_modbus_connection.connected is False async def test_unload_entry_does_not_close_connection_if_platform_unload_fails( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_lwz_api: MagicMock, + mock_modbus_connection: MockModbusConnection, ) -> None: """Test the connection is not closed if platform unload fails.""" mock_config_entry.add_to_hass(hass) @@ -127,4 +163,4 @@ async def test_unload_entry_does_not_close_connection_if_platform_unload_fails( await hass.async_block_till_done() assert result is False - mock_lwz_api.close.assert_not_awaited() + assert mock_modbus_connection.connected is True From af883932bbcd4968142ee4410007aaf2abfa7ed8 Mon Sep 17 00:00:00 2001 From: vemboy20 Date: Fri, 10 Jul 2026 12:24:43 -0700 Subject: [PATCH 458/707] Bump pyControl4 to 2.0.2 (#176050) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/control4/__init__.py | 17 ++++----- homeassistant/components/control4/climate.py | 20 +++++------ .../components/control4/config_flow.py | 8 ++--- homeassistant/components/control4/cover.py | 2 +- .../components/control4/director_utils.py | 6 ++-- homeassistant/components/control4/light.py | 10 +++--- .../components/control4/manifest.json | 2 +- .../components/control4/media_player.py | 22 ++++++------ requirements_all.txt | 2 +- script/hassfest/requirements.py | 2 ++ tests/components/control4/conftest.py | 35 ++++++++++--------- tests/components/control4/test_climate.py | 20 +++++------ tests/components/control4/test_config_flow.py | 8 ++--- tests/components/control4/test_cover.py | 6 ++-- 14 files changed, 79 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 5cb8eb0cf61b..e5fe5de9fef7 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -1,7 +1,6 @@ """The Control4 integration.""" from dataclasses import dataclass -import json import logging from typing import Any @@ -84,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> config = entry.data account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) try: - await account.getAccountBearerToken() + await account.get_account_bearer_token() except client_exceptions.ClientError as exception: _LOGGER.error("Error connecting to Control4 account API: %s", exception) raise ConfigEntryNotReady( @@ -103,7 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> controller_unique_id: str = config[CONF_CONTROLLER_UNIQUE_ID] director_token_dict = await call_c4_api_retry( - account.getDirectorBearerToken, controller_unique_id + account.get_director_bearer_token, controller_unique_id ) director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) @@ -111,9 +110,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"] + controller_href = (await call_c4_api_retry(account.get_account_controllers))["href"] director_sw_version = await call_c4_api_retry( - account.getControllerOSVersion, controller_href + account.get_controller_os_version, controller_href ) _, model, mac_address = controller_unique_id.split("_", 3) @@ -132,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> # Store all items found on controller for platforms to use try: - all_items_raw = await director.getAllItemInfo() + director_all_items: list[dict[str, Any]] = await director.get_all_item_info() except (TimeoutError, client_exceptions.ClientError) as err: _LOGGER.error( "Timeout connecting to Control4 controller at %s", @@ -142,13 +141,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> f"Timeout connecting to Control4 controller at {config[CONF_HOST]}" ) from err - director_all_items: list[dict[str, Any]] = json.loads(all_items_raw) - # Check if OS version is 3 or higher to get UI configuration ui_configuration: dict[str, Any] | None = None if int(director_sw_version.split(".")[0]) >= 3: try: - ui_config_raw = await director.getUiConfiguration() + ui_configuration = await director.get_ui_configuration() except (TimeoutError, client_exceptions.ClientError) as err: _LOGGER.error( "Timeout getting UI configuration from Control4 controller at %s", @@ -159,8 +156,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> f" Control4 controller at {config[CONF_HOST]}" ) from err - ui_configuration = json.loads(ui_config_raw) - # Load options from config entry scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index 1894498c93ed..764b8f9da14e 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -370,7 +370,7 @@ class Control4Climate(Control4Entity, ClimateEntity): """Set new target HVAC mode.""" c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode] c4_climate = self._create_api_object() - await c4_climate.setHvacMode(c4_hvac_mode) + await c4_climate.set_hvac_mode(c4_hvac_mode) await self.coordinator.async_request_refresh() @override @@ -385,26 +385,26 @@ class Control4Climate(Control4Entity, ClimateEntity): if self.hvac_mode == HVACMode.HEAT_COOL: if low_temp is not None: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setHeatSetpointC(low_temp) + await c4_climate.set_heat_setpoint_c(low_temp) else: - await c4_climate.setHeatSetpointF(low_temp) + await c4_climate.set_heat_setpoint_f(low_temp) if high_temp is not None: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setCoolSetpointC(high_temp) + await c4_climate.set_cool_setpoint_c(high_temp) else: - await c4_climate.setCoolSetpointF(high_temp) + await c4_climate.set_cool_setpoint_f(high_temp) # Handle single temperature setpoint elif temp is not None: if self.hvac_mode == HVACMode.COOL: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setCoolSetpointC(temp) + await c4_climate.set_cool_setpoint_c(temp) else: - await c4_climate.setCoolSetpointF(temp) + await c4_climate.set_cool_setpoint_f(temp) elif self.hvac_mode == HVACMode.HEAT: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setHeatSetpointC(temp) + await c4_climate.set_heat_setpoint_c(temp) else: - await c4_climate.setHeatSetpointF(temp) + await c4_climate.set_heat_setpoint_f(temp) await self.coordinator.async_request_refresh() @@ -412,5 +412,5 @@ class Control4Climate(Control4Entity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" c4_climate = self._create_api_object() - await c4_climate.setFanMode(fan_mode.title()) + await c4_climate.set_fan_mode(fan_mode.title()) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 508161ce7059..74f1c34d8224 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -64,13 +64,13 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): account_session = aiohttp_client.async_get_clientsession(self.hass) account = C4Account(username, password, account_session) try: - await account.getAccountBearerToken() + await account.get_account_bearer_token() - account_controllers = await account.getAccountControllers() + account_controllers = await account.get_account_controllers() controller_unique_id = account_controllers["controllerCommonName"] director_bearer_token = ( - await account.getDirectorBearerToken(controller_unique_id) + await account.get_director_bearer_token(controller_unique_id) )["token"] except BadCredentials, Unauthorized: errors["base"] = "invalid_auth" @@ -91,7 +91,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): ) director = C4Director(host, director_bearer_token, director_session) try: - await director.getAllItemInfo() + await director.get_all_item_info() except Unauthorized: errors["base"] = "director_auth_failed" return errors, data, description_placeholders diff --git a/homeassistant/components/control4/cover.py b/homeassistant/components/control4/cover.py index dba2efe87768..5b01ed406f1d 100644 --- a/homeassistant/components/control4/cover.py +++ b/homeassistant/components/control4/cover.py @@ -225,5 +225,5 @@ class Control4Cover(Control4Entity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" c4_blind = self._create_api_object() - await c4_blind.setLevelTarget(kwargs[ATTR_POSITION]) + await c4_blind.set_level_target(kwargs[ATTR_POSITION]) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index a26c5f9f4138..bcb1d50e377d 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -23,7 +23,7 @@ async def _update_variables_for_config_entry( ) -> dict[int, dict[str, Any]]: """Retrieve data from the Control4 director.""" director = entry.runtime_data.director - data = await director.getAllItemVariableValue(variable_names) + data = await director.get_all_item_variable_value(variable_names) result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) for item in data: result_dict[item["id"]][item["varName"]] = item["value"] @@ -48,10 +48,10 @@ async def refresh_tokens(hass: HomeAssistant, entry: Control4ConfigEntry): account_session = aiohttp_client.async_get_clientsession(hass) account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) - await account.getAccountBearerToken() + await account.get_account_bearer_token() controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] - director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_token_dict = await account.get_director_bearer_token(controller_unique_id) director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) director = C4Director( diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index c51909e2340c..aa40b041ac2c 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -115,7 +115,7 @@ async def async_setup_entry( item_coordinator = non_dimmer_coordinator else: director = runtime_data.director - item_variables = await director.getItemVariables(item_id) + item_variables = await director.get_item_variables(item_id) _LOGGER.warning( ( "Couldn't get light state data for %s, skipping setup. Available" @@ -229,10 +229,10 @@ class Control4Light(Control4Entity, LightEntity): brightness = (kwargs[ATTR_BRIGHTNESS] / 255) * 100 else: brightness = 100 - await c4_light.rampToLevel(brightness, transition_length) + await c4_light.ramp_to_level(brightness, transition_length) else: transition_length = 0 - await c4_light.setLevel(100) + await c4_light.set_level(100) if transition_length == 0: transition_length = 1000 delay_time = (transition_length / 1000) + 0.7 @@ -249,10 +249,10 @@ class Control4Light(Control4Entity, LightEntity): transition_length = kwargs[ATTR_TRANSITION] * 1000 else: transition_length = 0 - await c4_light.rampToLevel(0, transition_length) + await c4_light.ramp_to_level(0, transition_length) else: transition_length = 0 - await c4_light.setLevel(0) + await c4_light.set_level(0) if transition_length == 0: transition_length = 1500 delay_time = (transition_length / 1000) + 0.7 diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json index 685ae8bf05a3..865e9353c7a0 100644 --- a/homeassistant/components/control4/manifest.json +++ b/homeassistant/components/control4/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyControl4"], - "requirements": ["pyControl4==1.5.0"], + "requirements": ["pyControl4==2.0.2"], "ssdp": [ { "st": "c4:director" diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 6faea420eef8..757a84dc6ef6 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -359,9 +359,9 @@ class Control4Room(Control4Entity, MediaPlayerEntity): if avail_source.name == source: audio_only = _SourceType.VIDEO not in avail_source.source_type if audio_only: - await self._create_api_object().setAudioSource(avail_source.idx) + await self._create_api_object().set_audio_source(avail_source.idx) else: - await self._create_api_object().setVideoAndAudioSource( + await self._create_api_object().set_video_and_audio_source( avail_source.idx ) break @@ -371,50 +371,50 @@ class Control4Room(Control4Entity, MediaPlayerEntity): @override async def async_turn_off(self) -> None: """Turn off the room.""" - await self._create_api_object().setRoomOff() + await self._create_api_object().set_room_off() await self.coordinator.async_request_refresh() @override async def async_mute_volume(self, mute: bool) -> None: """Mute the room.""" if mute: - await self._create_api_object().setMuteOn() + await self._create_api_object().set_mute_on() else: - await self._create_api_object().setMuteOff() + await self._create_api_object().set_mute_off() await self.coordinator.async_request_refresh() @override async def async_set_volume_level(self, volume: float) -> None: """Set room volume, 0-1 scale.""" - await self._create_api_object().setVolume(int(volume * 100)) + await self._create_api_object().set_volume(int(volume * 100)) await self.coordinator.async_request_refresh() @override async def async_volume_up(self) -> None: """Increase the volume by 1.""" - await self._create_api_object().setIncrementVolume() + await self._create_api_object().set_increment_volume() await self.coordinator.async_request_refresh() @override async def async_volume_down(self) -> None: """Decrease the volume by 1.""" - await self._create_api_object().setDecrementVolume() + await self._create_api_object().set_decrement_volume() await self.coordinator.async_request_refresh() @override async def async_media_pause(self) -> None: """Issue a pause command.""" - await self._create_api_object().setPause() + await self._create_api_object().set_pause() await self.coordinator.async_request_refresh() @override async def async_media_play(self) -> None: """Issue a play command.""" - await self._create_api_object().setPlay() + await self._create_api_object().set_play() await self.coordinator.async_request_refresh() @override async def async_media_stop(self) -> None: """Issue a stop command.""" - await self._create_api_object().setStop() + await self._create_api_object().set_stop() await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 739c8423fc4d..fa5658a54118 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1967,7 +1967,7 @@ pyAtome==0.1.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==1.5.0 +pyControl4==2.0.2 # homeassistant.components.duotecno pyDuotecno==2024.10.1 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 7aba0b5e27df..db43686410e6 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -269,6 +269,8 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = { "pbr": {"setuptools"} }, "coinbase": {"homeassistant": {"coinbase-advanced-py"}}, + # https://github.com/lawtancool/pyControl4 - ships tests/ in wheel + "control4": {"homeassistant": {"pycontrol4"}}, # https://github.com/u9n/dlms-cosem "dsmr": {"dsmr-parser": {"dlms-cosem"}}, # https://github.com/tkdrob/pyefergy diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index 38300b88f0aa..de66348dfcc2 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Control4 tests.""" from collections.abc import AsyncGenerator, Generator +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -54,16 +55,18 @@ def mock_c4_account() -> Generator[MagicMock]: ), ): mock_account = mock_account_class.return_value - mock_account.getAccountBearerToken = AsyncMock() - mock_account.getAccountControllers = AsyncMock( + mock_account.get_account_bearer_token = AsyncMock() + mock_account.get_account_controllers = AsyncMock( return_value={ "controllerCommonName": "control4_model_00AA00AA00AA", "href": "https://apis.control4.com/account/v3/rest/accounts/000000", "name": "Name", } ) - mock_account.getDirectorBearerToken = AsyncMock(return_value={"token": "test"}) - mock_account.getControllerOSVersion = AsyncMock(return_value="3.2.0") + mock_account.get_director_bearer_token = AsyncMock( + return_value={"token": "test", "validSeconds": 86400} + ) + mock_account.get_controller_os_version = AsyncMock(return_value="3.2.0") yield mock_account @@ -80,14 +83,12 @@ def mock_c4_director() -> Generator[MagicMock]: ), ): mock_director = mock_director_class.return_value - # Multi-platform setup: media room, climate room, shared devices - # Note: The API returns JSON strings, so we load fixtures as strings - mock_director.getAllItemInfo = AsyncMock( - return_value=load_fixture("director_all_items.json", DOMAIN) - ) - mock_director.getUiConfiguration = AsyncMock( - return_value=load_fixture("ui_configuration.json", DOMAIN) + all_items = json.loads(load_fixture("director_all_items.json", DOMAIN)) + mock_director.get_all_item_info = AsyncMock(return_value=all_items) + mock_director.get_ui_configuration = AsyncMock( + return_value=json.loads(load_fixture("ui_configuration.json", DOMAIN)) ) + mock_director.get_item_variables = AsyncMock(return_value=[]) yield mock_director @@ -157,12 +158,12 @@ def mock_c4_climate() -> Generator[MagicMock]: "homeassistant.components.control4.climate.C4Climate", autospec=True ) as mock_class: mock_instance = mock_class.return_value - mock_instance.setHvacMode = AsyncMock() - mock_instance.setHeatSetpointF = AsyncMock() - mock_instance.setCoolSetpointF = AsyncMock() - mock_instance.setFanMode = AsyncMock() - mock_instance.setHeatSetpointC = AsyncMock() - mock_instance.setCoolSetpointC = AsyncMock() + mock_instance.set_hvac_mode = AsyncMock() + mock_instance.set_heat_setpoint_f = AsyncMock() + mock_instance.set_cool_setpoint_f = AsyncMock() + mock_instance.set_fan_mode = AsyncMock() + mock_instance.set_heat_setpoint_c = AsyncMock() + mock_instance.set_cool_setpoint_c = AsyncMock() yield mock_instance diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py index d7e348db9bb5..6f6f628cbfd5 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -253,7 +253,7 @@ async def test_set_hvac_mode( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) - mock_c4_climate.setHvacMode.assert_called_once_with(expected_c4_mode) + mock_c4_climate.set_hvac_mode.assert_called_once_with(expected_c4_mode) @pytest.mark.parametrize( @@ -265,7 +265,7 @@ async def test_set_hvac_mode( temperature=72.5, humidity=45, ), - "setHeatSetpointF", + "set_heat_setpoint_f", id="heat", ), pytest.param( @@ -275,7 +275,7 @@ async def test_set_hvac_mode( temperature=74.0, cool_setpoint=72.0, ), - "setCoolSetpointF", + "set_cool_setpoint_f", id="cool", ), ], @@ -330,8 +330,8 @@ async def test_set_temperature_range_auto_mode( }, blocking=True, ) - mock_c4_climate.setHeatSetpointF.assert_called_once_with(65.0) - mock_c4_climate.setCoolSetpointF.assert_called_once_with(78.0) + mock_c4_climate.set_heat_setpoint_f.assert_called_once_with(65.0) + mock_c4_climate.set_cool_setpoint_f.assert_called_once_with(78.0) @pytest.mark.parametrize("mock_climate_variables", [{}]) @@ -482,7 +482,7 @@ async def test_set_fan_mode( blocking=True, ) # Verify the Control4 API is called with the C4 format ("On" not "on") - mock_c4_climate.setFanMode.assert_called_once_with("On") + mock_c4_climate.set_fan_mode.assert_called_once_with("On") @pytest.mark.parametrize( @@ -530,14 +530,14 @@ async def test_fan_mode_not_supported( [ pytest.param( _make_climate_data(hvac_state="Off", hvac_mode="Heat"), - "setHeatSetpointF", - "setHeatSetpointC", + "set_heat_setpoint_f", + "set_heat_setpoint_c", id="fahrenheit_heat_calls_F_not_C", ), pytest.param( _make_climate_data(hvac_state="Cool", hvac_mode="Cool"), - "setCoolSetpointF", - "setCoolSetpointC", + "set_cool_setpoint_f", + "set_cool_setpoint_c", id="fahrenheit_cool_calls_F_not_C", ), ], diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index 773f692c2ef3..0e99ef1e4ade 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -80,7 +80,7 @@ async def test_user_flow_errors( DOMAIN, context={"source": SOURCE_USER} ) - mock_c4_account.getAccountBearerToken.side_effect = exception + mock_c4_account.get_account_bearer_token.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -94,7 +94,7 @@ async def test_user_flow_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_c4_account.getAccountBearerToken.side_effect = None + mock_c4_account.get_account_bearer_token.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -130,7 +130,7 @@ async def test_user_flow_director_errors( DOMAIN, context={"source": SOURCE_USER} ) - mock_c4_director.getAllItemInfo.side_effect = exception + mock_c4_director.get_all_item_info.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -144,7 +144,7 @@ async def test_user_flow_director_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_c4_director.getAllItemInfo.side_effect = None + mock_c4_director.get_all_item_info.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/control4/test_cover.py b/tests/components/control4/test_cover.py index 55b42165a3ef..84c81083905f 100644 --- a/tests/components/control4/test_cover.py +++ b/tests/components/control4/test_cover.py @@ -93,7 +93,7 @@ def mock_c4_blind() -> Generator[MagicMock]: mock_instance.open = AsyncMock() mock_instance.close = AsyncMock() mock_instance.stop = AsyncMock() - mock_instance.setLevelTarget = AsyncMock() + mock_instance.set_level_target = AsyncMock() yield mock_instance @@ -245,14 +245,14 @@ async def test_set_cover_position( hass: HomeAssistant, mock_c4_blind: MagicMock, ) -> None: - """Test setting cover position calls setLevelTarget with the requested value.""" + """Test setting cover position calls set_level_target with the requested value.""" await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 75}, blocking=True, ) - mock_c4_blind.setLevelTarget.assert_called_once_with(75) + mock_c4_blind.set_level_target.assert_called_once_with(75) @pytest.mark.parametrize("mock_cover_variables", [{}]) From 53a50594b9e5087117678fb167a17d96c3747221 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Fri, 10 Jul 2026 15:26:04 -0400 Subject: [PATCH 459/707] Refactor SupervisorIssues into a DataUpdateCoordinator (#176054) --- homeassistant/components/hassio/__init__.py | 35 +- homeassistant/components/hassio/const.py | 8 +- .../components/hassio/coordinator.py | 471 +++++++++++++++++- homeassistant/components/hassio/issues.py | 370 +------------- tests/components/conftest.py | 4 - tests/components/hassio/test_issues.py | 317 ++++++++++-- tests/conftest.py | 2 +- 7 files changed, 785 insertions(+), 422 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 3dc73beb3e83..231e7be94867 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -60,6 +60,7 @@ from .const import ( DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + ISSUE_MOUNT_MOUNT_FAILED, JOBS_COORDINATOR, MAIN_COORDINATOR, STATS_COORDINATOR, @@ -68,6 +69,9 @@ from .coordinator import ( HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + IssueSubscription, + IssueSubscriptionEvent, + SupervisorIssuesCoordinator, SupervisorJobsCoordinator, get_addons_info, get_addons_list, @@ -87,7 +91,6 @@ from .exceptions import HassioNotReadyError from .handler import HassIO, async_update_diagnostics, get_supervisor_client from .http import HassIOView from .ingress import async_setup_ingress_view -from .issues import SupervisorIssues from .services import async_setup_services from .websocket_api import async_load_websocket_api @@ -340,18 +343,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await stats_coordinator.async_config_entry_first_refresh() hass.data[STATS_COORDINATOR] = stats_coordinator - # All coordinators refreshed successfully. Start the issues listener and - # install the stop handler now so they are never left in a partial state - # if a coordinator refresh raises ConfigEntryNotReady. - hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass) + issues_coordinator = SupervisorIssuesCoordinator(hass, entry) + hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues_coordinator - def _unload_supervisor_issues() -> None: - if ( - supervisor_issues := hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None) - ) is not None: - supervisor_issues.unload() + @callback + def _refresh_main_coordinator_on_mount_issue(_: IssueSubscriptionEvent) -> None: + coordinator.config_entry.async_create_task(hass, coordinator.async_refresh()) - entry.async_on_unload(_unload_supervisor_issues) + entry.async_on_unload( + issues_coordinator.subscribe( + IssueSubscription( + event_callback=_refresh_main_coordinator_on_mount_issue, + key=ISSUE_MOUNT_MOUNT_FAILED, + ) + ) + ) async def _async_stop(hass: HomeAssistant, restart: bool) -> None: """Stop or restart home assistant.""" @@ -407,9 +413,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Failed to update Home Assistant options in Supervisor: %s", err ) - # Push initial config to Supervisor and start issues listener + # Push initial config to Supervisor and refresh issues state await asyncio.gather( - update_hass_api(refresh_token), push_config(None), issues.setup() + update_hass_api(refresh_token), + push_config(None), + issues_coordinator.async_refresh(), ) # Setup hardware integration for the detected board type @@ -442,5 +450,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(ADDONS_COORDINATOR, None) hass.data.pop(STATS_COORDINATOR, None) hass.data.pop(JOBS_COORDINATOR, None) + hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None) return unload_ok diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 8f68e0903238..a33ab7fb1115 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -27,10 +27,10 @@ if TYPE_CHECKING: HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + SupervisorIssuesCoordinator, SupervisorJobsCoordinator, ) from .handler import HassIO - from .issues import SupervisorIssues DOMAIN = "hassio" @@ -130,6 +130,7 @@ DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list") HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5) HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15) HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60) +HASSIO_ISSUES_UPDATE_INTERVAL = timedelta(minutes=30) SUPERVISOR_JOBS_UPDATE_INTERVAL = timedelta(minutes=15) ATTR_AUTO_UPDATE = "auto_update" @@ -148,7 +149,9 @@ DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" -DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues") +DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssuesCoordinator] = HassKey( + "supervisor_issues" +) DATA_KEY_MOUNTS = "mounts" DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host") DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") @@ -160,6 +163,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" PLACEHOLDER_KEY_FREE_SPACE = "free_space" +PLACEHOLDER_KEY_REASON = "reason" ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index aeadbb419fe2..191d3b6e2338 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -5,7 +5,7 @@ from collections import defaultdict from collections.abc import Awaitable, Callable from dataclasses import dataclass, replace import logging -from typing import TYPE_CHECKING, Any, cast, override +from typing import Any, Literal, cast, override from uuid import UUID from aiohasupervisor import SupervisorError, SupervisorNotFoundError @@ -18,6 +18,7 @@ from aiohasupervisor.models import ( HostInfo, InstalledAddon, InstalledAddonComplete, + Issue as SupervisorIssue, Job, NetworkInfo, NFSMountResponse, @@ -27,10 +28,12 @@ from aiohasupervisor.models import ( StoreInfo, SupervisorInfo, SupervisorStats, + UnhealthyReason, + UnsupportedReason, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MANUFACTURER +from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME from homeassistant.core import ( CALLBACK_TYPE, HomeAssistant, @@ -41,13 +44,23 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_ADDONS, ATTR_DATA, + ATTR_HEALTHY, ATTR_REPOSITORIES, + ATTR_SLUG, ATTR_STARTUP, + ATTR_SUPPORTED, + ATTR_UNHEALTHY_REASONS, + ATTR_UNSUPPORTED_REASONS, ATTR_UPDATE_KEY, ATTR_WS_EVENT, CONTAINER_STATS, @@ -66,12 +79,31 @@ from .const import ( DATA_SUPERVISOR_INFO, DATA_SUPERVISOR_STATS, DOMAIN, + EVENT_HEALTH_CHANGED, + EVENT_ISSUE_CHANGED, + EVENT_ISSUE_REMOVED, EVENT_JOB, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, + EVENT_SUPPORTED_CHANGED, + EXTRA_PLACEHOLDERS, HASSIO_ADDON_UPDATE_INTERVAL, + HASSIO_ISSUES_UPDATE_INTERVAL, HASSIO_MAIN_UPDATE_INTERVAL, HASSIO_STATS_UPDATE_INTERVAL, + ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + ISSUE_KEY_ADDON_PWNED, + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + ISSUE_KEY_SYSTEM_FREE_SPACE, + ISSUE_MOUNT_MOUNT_FAILED, + PLACEHOLDER_KEY_ADDON, + PLACEHOLDER_KEY_ADDON_URL, + PLACEHOLDER_KEY_FREE_SPACE, + PLACEHOLDER_KEY_REASON, + PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, STARTUP_COMPLETE, SUPERVISOR_CONTAINER, @@ -81,12 +113,439 @@ from .const import ( ) from .exceptions import HassioNotReadyError from .handler import get_supervisor_client - -if TYPE_CHECKING: - from .issues import SupervisorIssues +from .issues import Issue, IssueDataType, Suggestion _LOGGER = logging.getLogger(__name__) +ISSUE_KEY_UNHEALTHY = "unhealthy" +ISSUE_KEY_UNSUPPORTED = "unsupported" +ISSUE_ID_UNHEALTHY = "unhealthy_system" +ISSUE_ID_UNSUPPORTED = "unsupported_system" + +INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" +INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" + +# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason +# provides no additional information beyond the unhealthy one then skip that repair. +UNSUPPORTED_SKIP_REPAIR = {"privileged"} + +# Keys (type + context) of issues that when found should be made into a repair. +ISSUE_KEYS_FOR_REPAIRS = { + ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_MOUNT_MOUNT_FAILED, + "issue_system_multiple_data_disks", + "issue_system_reboot_required", + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + "issue_system_disk_lifetime", + ISSUE_KEY_SYSTEM_FREE_SPACE, + ISSUE_KEY_ADDON_PWNED, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, + "issue_system_ntp_sync_failed", +} + + +@dataclass(slots=True, frozen=True) +class IssueSubscription: + """Subscribe for updates on supervisor issues matching a key.""" + + event_callback: Callable[[IssueSubscriptionEvent], None] + key: str + + def __post_init__(self) -> None: + """Validate inputs.""" + if not self.key: + raise ValueError("A key must be provided!") + if not is_callback_check_partial(self.event_callback): + raise ValueError("event_callback must be a homeassistant.core.callback!") + + def matches(self, issue: Issue) -> bool: + """Return true if issue matches this subscription.""" + return issue.key == self.key + + +@dataclass(slots=True, frozen=True) +class IssueSubscriptionEvent: + """Issue subscription event.""" + + event: Literal["changed", "removed"] + issue: Issue + + +@dataclass(slots=True, frozen=True) +class SupervisorIssuesData: + """Data class for supervisor issues.""" + + unhealthy_reasons: set[str] + unsupported_reasons: set[str] + issues: dict[UUID, Issue] + + +class SupervisorIssuesCoordinator(DataUpdateCoordinator[SupervisorIssuesData]): + """Manage supervisor issues state and repair synchronization.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize supervisor issues coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="SupervisorIssuesCoordinator", + update_interval=HASSIO_ISSUES_UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self._supervisor_client = get_supervisor_client(hass) + self._subscriptions: set[IssueSubscription] = set() + self._dispatcher_disconnect: Callable[[], None] | None = ( + async_dispatcher_connect( + self.hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues + ) + ) + # Keep polling active even if initial refresh fails so coordinator can recover. + self.async_add_listener(lambda: None) + + @property + def unhealthy_reasons(self) -> set[str]: + """Get unhealthy reasons. Returns empty set if system is healthy.""" + return self.data.unhealthy_reasons if self.data is not None else set() + + @property + def unsupported_reasons(self) -> set[str]: + """Get unsupported reasons. Returns empty set if system is supported.""" + return self.data.unsupported_reasons if self.data is not None else set() + + @property + def issues(self) -> set[Issue]: + """Get issues.""" + if self.data is None: + return set() + return set(self.data.issues.values()) + + def get_issue(self, issue_id: str) -> Issue | None: + """Get issue from key.""" + if self.data is None: + return None + return self.data.issues.get(UUID(issue_id)) + + def subscribe(self, subscription: IssueSubscription) -> CALLBACK_TYPE: + """Subscribe to updates for issue key. Callback is used to unsubscribe.""" + self._subscriptions.add(subscription) + + for match in [issue for issue in self.issues if subscription.matches(issue)]: + self._notify_issue_subscription_event( + subscription, IssueSubscriptionEvent(event="changed", issue=match) + ) + + def _unsubscribe() -> None: + self._subscriptions.discard(subscription) + + return _unsubscribe + + def _process_issue_change(self, event: IssueSubscriptionEvent) -> None: + """Process an issue change by triggering callbacks on subscribers.""" + for sub in self._subscriptions: + if sub.matches(event.issue): + self._notify_issue_subscription_event(sub, event) + + def _notify_issue_subscription_event( + self, subscription: IssueSubscription, event: IssueSubscriptionEvent + ) -> None: + """Run a subscription callback and log callback failures.""" + try: + subscription.event_callback(event) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error encountered processing Supervisor issue (%s %s %s) - %s", + event.issue.key, + event.issue.reference, + event.issue.uuid, + err, + ) + + @staticmethod + def _issue_equal(previous_issue: Issue, issue: Issue) -> bool: + """Return true if issues are equal including suggestions.""" + return ( + previous_issue == issue and previous_issue.suggestions == issue.suggestions + ) + + def _process_reason_deltas( + self, + previous_data: SupervisorIssuesData, + current_data: SupervisorIssuesData, + ) -> None: + """Create/delete unhealthy and unsupported repairs based on reason deltas.""" + for unhealthy in ( + current_data.unhealthy_reasons - previous_data.unhealthy_reasons + ): + if unhealthy in UnhealthyReason: + translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" + translation_placeholders = None + else: + translation_key = ISSUE_KEY_UNHEALTHY + translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} + + async_create_issue( + self.hass, + DOMAIN, + f"{ISSUE_ID_UNHEALTHY}_{unhealthy}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}", + severity=IssueSeverity.CRITICAL, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in previous_data.unhealthy_reasons - current_data.unhealthy_reasons: + async_delete_issue(self.hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}") + + for unsupported in ( + current_data.unsupported_reasons + - UNSUPPORTED_SKIP_REPAIR + - previous_data.unsupported_reasons + ): + if unsupported in UnsupportedReason: + translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" + translation_placeholders = None + else: + translation_key = ISSUE_KEY_UNSUPPORTED + translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} + + async_create_issue( + self.hass, + DOMAIN, + f"{ISSUE_ID_UNSUPPORTED}_{unsupported}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}", + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in previous_data.unsupported_reasons - ( + current_data.unsupported_reasons - UNSUPPORTED_SKIP_REPAIR + ): + async_delete_issue(self.hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}") + + def _create_or_update_issue_repair(self, issue: Issue) -> None: + """Create/update a repair for an issue if needed.""" + if issue.key not in ISSUE_KEYS_FOR_REPAIRS: + return + + if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: + placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy() + else: + placeholders = {} + + if issue.reference: + placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference + + if issue.key in { + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_PWNED, + }: + placeholders[PLACEHOLDER_KEY_ADDON_URL] = ( + f"/hassio/addon/{issue.reference}" + ) + addons_list = get_addons_list(self.hass) or [] + placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference + for addon in addons_list: + if addon[ATTR_SLUG] == issue.reference: + placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME] + break + + elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE: + host_info = get_host_info(self.hass) + if host_info and "disk_free" in host_info: + placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(host_info["disk_free"]) + else: + placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2" + + async_create_issue( + self.hass, + DOMAIN, + issue.uuid.hex, + is_fixable=bool(issue.suggestions), + severity=IssueSeverity.WARNING, + translation_key=issue.key, + translation_placeholders=placeholders or None, + ) + + def _delete_issue_repair(self, issue: Issue) -> None: + """Delete repair for issue if it maps to a repair.""" + if issue.key in ISSUE_KEYS_FOR_REPAIRS: + async_delete_issue(self.hass, DOMAIN, issue.uuid.hex) + + def _process_issue_deltas( + self, + previous_data: SupervisorIssuesData, + current_data: SupervisorIssuesData, + ) -> None: + """Create/delete issue repairs and notify subscribers based on issue deltas.""" + for issue in current_data.issues.values(): + previous_issue = previous_data.issues.get(issue.uuid) + if previous_issue is not None and self._issue_equal(previous_issue, issue): + continue + + self._create_or_update_issue_repair(issue) + self._process_issue_change( + IssueSubscriptionEvent(event="changed", issue=issue) + ) + + for issue_uuid, issue in previous_data.issues.items(): + if issue_uuid not in current_data.issues: + self._delete_issue_repair(issue) + self._process_issue_change( + IssueSubscriptionEvent(event="removed", issue=issue) + ) + + @override + async def _async_update_data(self) -> SupervisorIssuesData: + """Update issues data from Supervisor resolution center.""" + try: + data = await self._supervisor_client.resolution.info() + except SupervisorError as err: + raise UpdateFailed(f"Error on Supervisor API: {err}") from err + + issue_from_data_results = await asyncio.gather( + *(self._issue_from_data(issue) for issue in data.issues) + ) + issues = { + issue_from_data.uuid: issue_from_data + for issue_from_data in issue_from_data_results + if issue_from_data is not None + } + + return SupervisorIssuesData( + unhealthy_reasons={str(reason) for reason in data.unhealthy}, + unsupported_reasons={str(reason) for reason in data.unsupported}, + issues=issues, + ) + + async def _issue_from_data(self, data: SupervisorIssue) -> Issue | None: + """Build an Issue model from Supervisor issue data and fetched suggestions.""" + try: + suggestions = ( + await self._supervisor_client.resolution.suggestions_for_issue( + data.uuid + ) + ) + except SupervisorError: + _LOGGER.error( + "Could not get suggestions for supervisor issue %s, skipping it", + data.uuid.hex, + ) + return None + + return Issue( + uuid=data.uuid, + type=str(data.type), + context=data.context, + reference=data.reference, + suggestions=[ + Suggestion( + uuid=suggestion.uuid, + type=str(suggestion.type), + context=suggestion.context, + reference=suggestion.reference, + ) + for suggestion in suggestions + ], + ) + + @override + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh issue data and apply repair/subscription deltas.""" + previous_data = self.data or SupervisorIssuesData(set(), set(), {}) + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) + if self.last_update_success and self.data is not None: + self._process_reason_deltas(previous_data, self.data) + self._process_issue_deltas(previous_data, self.data) + + @override + async def async_shutdown(self) -> None: + """Shut down the coordinator.""" + await super().async_shutdown() + if self._dispatcher_disconnect: + self._dispatcher_disconnect() + self._dispatcher_disconnect = None + + @callback + def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: + """Update issues data from supervisor events.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE + ): + self.config_entry.async_create_task(self.hass, self.async_refresh()) + return + + previous_data = self.data or SupervisorIssuesData(set(), set(), {}) + + if event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: + unhealthy_reasons = ( + set() + if event[ATTR_DATA][ATTR_HEALTHY] + else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS]) + ) + updated_data = SupervisorIssuesData( + unhealthy_reasons=unhealthy_reasons, + unsupported_reasons=set(previous_data.unsupported_reasons), + issues=dict(previous_data.issues), + ) + elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED: + unsupported_reasons = ( + set() + if event[ATTR_DATA][ATTR_SUPPORTED] + else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) + ) + updated_data = SupervisorIssuesData( + unhealthy_reasons=set(previous_data.unhealthy_reasons), + unsupported_reasons=unsupported_reasons, + issues=dict(previous_data.issues), + ) + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED: + issue = Issue.from_dict(cast(IssueDataType, event[ATTR_DATA])) + updated_issues = dict(previous_data.issues) + updated_issues[issue.uuid] = issue + updated_data = SupervisorIssuesData( + unhealthy_reasons=set(previous_data.unhealthy_reasons), + unsupported_reasons=set(previous_data.unsupported_reasons), + issues=updated_issues, + ) + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED: + issue = Issue.from_dict(cast(IssueDataType, event[ATTR_DATA])) + updated_issues = dict(previous_data.issues) + updated_issues.pop(issue.uuid, None) + updated_data = SupervisorIssuesData( + unhealthy_reasons=set(previous_data.unhealthy_reasons), + unsupported_reasons=set(previous_data.unsupported_reasons), + issues=updated_issues, + ) + else: + return + + self.async_set_updated_data(updated_data) + self._process_reason_deltas(previous_data, updated_data) + self._process_issue_deltas(previous_data, updated_data) + @dataclass(slots=True, frozen=True) class JobSubscription: @@ -546,7 +1005,7 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any]: @callback -def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: +def get_issues_info(hass: HomeAssistant) -> SupervisorIssuesCoordinator | None: """Return Supervisor issues info. Async friendly. diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index b8d14947c0ca..ffdbe4befb29 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -1,100 +1,10 @@ -"""Supervisor events monitor.""" +"""Supervisor issue models.""" -import asyncio -from collections.abc import Callable from dataclasses import dataclass, field -from datetime import datetime -import logging -from typing import Any, NotRequired, TypedDict +from typing import NotRequired, TypedDict from uuid import UUID -from aiohasupervisor import SupervisorError -from aiohasupervisor.models import ( - ContextType, - Issue as SupervisorIssue, - UnhealthyReason, - UnsupportedReason, -) - -from homeassistant.const import ATTR_NAME -from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) - -from .const import ( - ATTR_DATA, - ATTR_HEALTHY, - ATTR_SLUG, - ATTR_STARTUP, - ATTR_SUPPORTED, - ATTR_UNHEALTHY_REASONS, - ATTR_UNSUPPORTED_REASONS, - ATTR_UPDATE_KEY, - ATTR_WS_EVENT, - DOMAIN, - EVENT_HEALTH_CHANGED, - EVENT_ISSUE_CHANGED, - EVENT_ISSUE_REMOVED, - EVENT_SUPERVISOR_EVENT, - EVENT_SUPERVISOR_UPDATE, - EVENT_SUPPORTED_CHANGED, - EXTRA_PLACEHOLDERS, - ISSUE_KEY_ADDON_BOOT_FAIL, - ISSUE_KEY_ADDON_DEPRECATED_ARCH, - ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, - ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, - ISSUE_KEY_ADDON_PWNED, - ISSUE_KEY_SYSTEM_DOCKER_CONFIG, - ISSUE_KEY_SYSTEM_FREE_SPACE, - ISSUE_MOUNT_MOUNT_FAILED, - MAIN_COORDINATOR, - PLACEHOLDER_KEY_ADDON, - PLACEHOLDER_KEY_ADDON_URL, - PLACEHOLDER_KEY_FREE_SPACE, - PLACEHOLDER_KEY_REFERENCE, - REQUEST_REFRESH_DELAY, - STARTUP_COMPLETE, - UPDATE_KEY_SUPERVISOR, -) -from .coordinator import HassioMainDataUpdateCoordinator, get_addons_list, get_host_info -from .handler import get_supervisor_client - -ISSUE_KEY_UNHEALTHY = "unhealthy" -ISSUE_KEY_UNSUPPORTED = "unsupported" -ISSUE_ID_UNHEALTHY = "unhealthy_system" -ISSUE_ID_UNSUPPORTED = "unsupported_system" - -INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" -INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" - -PLACEHOLDER_KEY_REASON = "reason" - -# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason -# provides no additional information beyond the unhealthy one then skip that repair. -UNSUPPORTED_SKIP_REPAIR = {"privileged"} - -# Keys (type + context) of issues that when found should be made into a repair -ISSUE_KEYS_FOR_REPAIRS = { - ISSUE_KEY_ADDON_BOOT_FAIL, - ISSUE_MOUNT_MOUNT_FAILED, - "issue_system_multiple_data_disks", - "issue_system_reboot_required", - ISSUE_KEY_SYSTEM_DOCKER_CONFIG, - ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, - ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, - "issue_system_disk_lifetime", - ISSUE_KEY_SYSTEM_FREE_SPACE, - ISSUE_KEY_ADDON_PWNED, - ISSUE_KEY_ADDON_DEPRECATED_ARCH, - "issue_system_ntp_sync_failed", -} - -_LOGGER = logging.getLogger(__name__) +from aiohasupervisor.models import ContextType class SuggestionDataType(TypedDict): @@ -169,277 +79,3 @@ class Issue: Suggestion.from_dict(suggestion) for suggestion in suggestions ], ) - - -class SupervisorIssues: - """Create issues from supervisor events.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize supervisor issues.""" - self._hass = hass - self._unsupported_reasons: set[str] = set() - self._unhealthy_reasons: set[str] = set() - self._issues: dict[UUID, Issue] = {} - self._supervisor_client = get_supervisor_client(hass) - self._disconnect: Callable[[], None] | None = None - self._cancel_update_retry: Callable[[], None] | None = None - - @property - def unhealthy_reasons(self) -> set[str]: - """Get unhealthy reasons. Returns empty set if system is healthy.""" - return self._unhealthy_reasons - - @unhealthy_reasons.setter - def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete repairs as necessary.""" - for unhealthy in reasons - self.unhealthy_reasons: - if unhealthy in UnhealthyReason: - translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" - translation_placeholders = None - else: - translation_key = ISSUE_KEY_UNHEALTHY - translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} - - async_create_issue( - self._hass, - DOMAIN, - f"{ISSUE_ID_UNHEALTHY}_{unhealthy}", - is_fixable=False, - learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}", - severity=IssueSeverity.CRITICAL, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - for fixed in self.unhealthy_reasons - reasons: - async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}") - - self._unhealthy_reasons = reasons - - @property - def unsupported_reasons(self) -> set[str]: - """Get unsupported reasons. Returns empty set if system is supported.""" - return self._unsupported_reasons - - @unsupported_reasons.setter - def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete repairs as necessary.""" - for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: - if unsupported in UnsupportedReason: - translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" - translation_placeholders = None - else: - translation_key = ISSUE_KEY_UNSUPPORTED - translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} - - async_create_issue( - self._hass, - DOMAIN, - f"{ISSUE_ID_UNSUPPORTED}_{unsupported}", - is_fixable=False, - learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}", - severity=IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR): - async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}") - - self._unsupported_reasons = reasons - - @property - def issues(self) -> set[Issue]: - """Get issues.""" - return set(self._issues.values()) - - def add_issue(self, issue: Issue) -> None: - """Add or update an issue in the list. - - Create or update a repair if necessary. - """ - if issue.key in ISSUE_KEYS_FOR_REPAIRS: - if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: - placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy() - else: - placeholders = {} - - if issue.reference: - placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference - - if issue.key in { - ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, - ISSUE_KEY_ADDON_PWNED, - }: - placeholders[PLACEHOLDER_KEY_ADDON_URL] = ( - f"/hassio/addon/{issue.reference}" - ) - addons_list = get_addons_list(self._hass) or [] - placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference - for addon in addons_list: - if addon[ATTR_SLUG] == issue.reference: - placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME] - break - - elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE: - host_info = get_host_info(self._hass) - if host_info and "disk_free" in host_info: - placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str( - host_info["disk_free"] - ) - else: - placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2" - - if issue.key == ISSUE_MOUNT_MOUNT_FAILED: - self._async_coordinator_refresh() - - async_create_issue( - self._hass, - DOMAIN, - issue.uuid.hex, - is_fixable=bool(issue.suggestions), - severity=IssueSeverity.WARNING, - translation_key=issue.key, - translation_placeholders=placeholders or None, - ) - - self._issues[issue.uuid] = issue - - async def add_issue_from_data(self, data: SupervisorIssue) -> None: - """Add issue from data to list after getting latest suggestions.""" - try: - suggestions = ( - await self._supervisor_client.resolution.suggestions_for_issue( - data.uuid - ) - ) - except SupervisorError: - _LOGGER.error( - "Could not get suggestions for supervisor issue %s, skipping it", - data.uuid.hex, - ) - return - self.add_issue( - Issue( - uuid=data.uuid, - type=str(data.type), - context=data.context, - reference=data.reference, - suggestions=[ - Suggestion( - uuid=suggestion.uuid, - type=str(suggestion.type), - context=suggestion.context, - reference=suggestion.reference, - ) - for suggestion in suggestions - ], - ) - ) - - def remove_issue(self, issue: Issue) -> None: - """Remove an issue from the list. Delete a repair if necessary.""" - if issue.uuid not in self._issues: - return - - if issue.key in ISSUE_KEYS_FOR_REPAIRS: - async_delete_issue(self._hass, DOMAIN, issue.uuid.hex) - - if issue.key == ISSUE_MOUNT_MOUNT_FAILED: - self._async_coordinator_refresh() - - del self._issues[issue.uuid] - - def get_issue(self, issue_id: str) -> Issue | None: - """Get issue from key.""" - return self._issues.get(UUID(issue_id)) - - async def setup(self) -> None: - """Create supervisor events listener.""" - await self.async_update() - - self._disconnect = async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues - ) - - def unload(self) -> None: - """Remove supervisor events listener.""" - if self._disconnect is not None: - self._disconnect() - self._disconnect = None - if self._cancel_update_retry is not None: - self._cancel_update_retry() - self._cancel_update_retry = None - - async def async_update(self) -> None: - """Update issues from Supervisor resolution center.""" - if self._cancel_update_retry: - self._cancel_update_retry() - self._cancel_update_retry = None - await self._update() - - async def _update(self, _: datetime | None = None) -> None: - """Update issues from Supervisor resolution center with retry on failure.""" - try: - data = await self._supervisor_client.resolution.info() - except SupervisorError as err: - _LOGGER.error("Failed to update supervisor issues: %r", err) - self._cancel_update_retry = async_call_later( - self._hass, - REQUEST_REFRESH_DELAY, - HassJob(self._update, cancel_on_shutdown=True), - ) - return - self._cancel_update_retry = None - self.unhealthy_reasons = set(data.unhealthy) - self.unsupported_reasons = set(data.unsupported) - - # Remove any cached issues that weren't returned - for issue_id in set(self._issues) - {issue.uuid for issue in data.issues}: - self.remove_issue(self._issues[issue_id]) - - # Add/update any issues that came back - await asyncio.gather( - *[self.add_issue_from_data(issue) for issue in data.issues] - ) - - @callback - def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: - """Create issues from supervisor events.""" - if ATTR_WS_EVENT not in event: - return - - if ( - event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE - and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR - and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE - ): - self._hass.async_create_task(self.async_update()) - - elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: - self.unhealthy_reasons = ( - set() - if event[ATTR_DATA][ATTR_HEALTHY] - else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS]) - ) - - elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED: - self.unsupported_reasons = ( - set() - if event[ATTR_DATA][ATTR_SUPPORTED] - else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) - ) - - elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED: - self.add_issue(Issue.from_dict(event[ATTR_DATA])) - - elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED: - self.remove_issue(Issue.from_dict(event[ATTR_DATA])) - - def _async_coordinator_refresh(self) -> None: - """Refresh coordinator to update latest data in entities.""" - coordinator: HassioMainDataUpdateCoordinator | None - if coordinator := self._hass.data.get(MAIN_COORDINATOR): - coordinator.config_entry.async_create_task( - self._hass, coordinator.async_refresh() - ) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 31facbab450a..8fe4d8d26b76 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -895,10 +895,6 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.coordinator.get_supervisor_client", return_value=supervisor_client, ), - patch( - "homeassistant.components.hassio.issues.get_supervisor_client", - return_value=supervisor_client, - ), patch( "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 2da8b47eb394..de63f13775c0 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -1,7 +1,6 @@ """Test issues from supervisor issues.""" from collections.abc import Generator -from datetime import timedelta import os from typing import Any from unittest.mock import ANY, AsyncMock, patch @@ -24,17 +23,22 @@ from aiohasupervisor.models import ( UnhealthyReason, UnsupportedReason, ) -from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.hassio.const import DOMAIN -from homeassistant.components.hassio.coordinator import get_issues_info +from homeassistant.components.hassio.const import DOMAIN, HASSIO_ISSUES_UPDATE_INTERVAL +from homeassistant.components.hassio.coordinator import ( + IssueSubscription, + IssueSubscriptionEvent, + get_issues_info, +) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .test_init import MOCK_ENVIRON +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -53,8 +57,8 @@ def fixture_supervisor_environ() -> Generator[None]: def mock_resolution_info( supervisor_client: AsyncMock, - unsupported: list[UnsupportedReason] | None = None, - unhealthy: list[UnhealthyReason] | None = None, + unsupported: list[UnsupportedReason | str] | None = None, + unhealthy: list[UnhealthyReason | str] | None = None, issues: list[Issue] | None = None, suggestions_by_issue: dict[UUID, list[Suggestion]] | None = None, suggestion_result: SupervisorError | None = None, @@ -683,9 +687,8 @@ async def test_supervisor_issues_initial_failure( supervisor_client: AsyncMock, resolution_info: AsyncMock, hass_ws_client: WebSocketGenerator, - freezer: FrozenDateTimeFactory, ) -> None: - """Test issues manager retries after initial update failure.""" + """Test initial issues refresh failure does not block hassio setup.""" mock_resolution_info( supervisor_client, unsupported=[], @@ -715,24 +718,14 @@ async def test_supervisor_issues_initial_failure( resolution_info.return_value, ] - with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1): - result = await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert result + result = await async_setup_component(hass, DOMAIN, {}) + assert result - client = await hass_ws_client(hass) - - await client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 0 - - freezer.tick(timedelta(milliseconds=200)) - await hass.async_block_till_done() - await client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 1 + client = await hass_ws_client(hass) + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 @pytest.mark.usefixtures("all_setup_requests") @@ -884,15 +877,16 @@ async def test_supervisor_remove_missing_issue_without_error( async def test_system_is_not_ready( hass: HomeAssistant, resolution_info: AsyncMock, - caplog: pytest.LogCaptureFixture, ) -> None: - """Ensure hassio starts despite error.""" + """Ensure hassio starts despite issues refresh errors.""" resolution_info.side_effect = SupervisorBadRequestError( "System is not ready with state: setup" ) assert await async_setup_component(hass, DOMAIN, {}) - assert "Failed to update supervisor issues" in caplog.text + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + assert not issues_coordinator.issues @pytest.mark.parametrize( @@ -1157,6 +1151,271 @@ async def test_supervisor_issues_addon_pwned( ) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_subscription_events( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, +) -> None: + """Test subscription callbacks for issue add/update/remove by key.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + + events: list[str] = [] + + @callback + def _handle_subscription_event(event: IssueSubscriptionEvent) -> None: + events.append(event.event) + + unsubscribe = issues_coordinator.subscribe( + IssueSubscription( + event_callback=_handle_subscription_event, + key="issue_system_should_not_be_repair", + ) + ) + + client = await hass_supervisor_ws_client() + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "should_not_be_repair", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed"] + + await client.send_json( + { + "id": 2, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": issue_uuid, + "type": "should_not_be_repair", + "context": "system", + "reference": "updated", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed", "changed"] + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "issue_removed", + "data": { + "uuid": issue_uuid, + "type": "should_not_be_repair", + "context": "system", + "reference": "updated", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed", "changed", "removed"] + + unsubscribe() + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_periodic_refresh_backstop( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test issues coordinator polls periodically without requiring subscribers.""" + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type="should_not_be_repair", + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + ) + ], + ) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + + supervisor_client.resolution.info.reset_mock() + + async_fire_time_changed(hass, dt_util.utcnow() + HASSIO_ISSUES_UPDATE_INTERVAL) + await hass.async_block_till_done() + + supervisor_client.resolution.info.assert_called_once() + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_suggestions_change_updates_fixable_state( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, +) -> None: + """Test suggestion-only issue changes are not treated as unchanged.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + supervisor_client.resolution.info.reset_mock() + issue_uuid = uuid4() + + supervisor_client.resolution.info.return_value = ResolutionInfo( + unsupported=[], + unhealthy=[], + issues=[ + Issue( + type="should_not_be_repair", + context=ContextType.SYSTEM, + reference=None, + uuid=issue_uuid, + ) + ], + suggestions=[ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ) + ], + checks=[ + Check(enabled=True, slug=CheckType.DOCKER_CONFIG), + Check(enabled=True, slug=CheckType.FREE_SPACE), + ], + ) + supervisor_client.resolution.suggestions_for_issue.return_value = [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ) + ] + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + events: list[str] = [] + + @callback + def _subscription_event(event: IssueSubscriptionEvent) -> None: + events.append(event.event) + + unsubscribe = issues_coordinator.subscribe( + IssueSubscription( + event_callback=_subscription_event, + key="issue_system_should_not_be_repair", + ) + ) + + supervisor_client_ws = await hass_supervisor_ws_client() + await supervisor_client_ws.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": issue_uuid.hex, + "type": "should_not_be_repair", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await supervisor_client_ws.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed"] + + await issues_coordinator.async_refresh() + await hass.async_block_till_done() + assert events == ["changed", "changed"] + + unsubscribe() + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_periodic_refresh_recovers_after_initial_failure( + hass: HomeAssistant, + supervisor_client: AsyncMock, + resolution_info: AsyncMock, +) -> None: + """Test a later refresh recovers issue state after initial refresh failure.""" + issue_uuid = uuid4() + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type="should_not_be_repair", + context=ContextType.SYSTEM, + reference=None, + uuid=issue_uuid, + ) + ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ) + ] + }, + ) + resolution_info.side_effect = [ + SupervisorBadRequestError("System is not ready with state: setup"), + resolution_info.return_value, + ] + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + assert len(issues_coordinator.issues) == 0 + + await issues_coordinator.async_refresh() + await hass.async_block_till_done() + assert len(issues_coordinator.issues) == 1 + + @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_unload_disconnects_listener( hass: HomeAssistant, diff --git a/tests/conftest.py b/tests/conftest.py index 9b1c732efb9e..5fba335c33a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2069,7 +2069,7 @@ async def hassio_stubs( ) -> None: """Create mock hassio http client.""" with patch( - "homeassistant.components.hassio.issues.SupervisorIssues.setup", + "homeassistant.components.hassio.coordinator.SupervisorIssuesCoordinator.async_refresh", ): await async_setup_component(hass, "hassio", {}) From dd6b85f5550d5447a0e38625fb72c0963a80ac5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jul 2026 09:26:17 -1000 Subject: [PATCH 460/707] Bump aioesphomeapi to 45.6.0 (#176157) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/esphome/snapshots/test_diagnostics.ambr | 1 + tests/components/esphome/test_diagnostics.py | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 867c4c473c47..7eb5b9744daf 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==45.5.2", + "aioesphomeapi==45.6.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.9.7" ], diff --git a/requirements_all.txt b/requirements_all.txt index fa5658a54118..f9fe758b27a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==45.5.2 +aioesphomeapi==45.6.0 # homeassistant.components.matrix # homeassistant.components.slack diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 62cd3c5c39af..6bd490685735 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -82,6 +82,7 @@ 'minor': 99, }), 'device_info': dict({ + 'api_encryption_provisionable': False, 'api_encryption_supported': False, 'area': dict({ 'area_id': 0, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 158954ca374b..7ecc3dc88721 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -129,6 +129,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "api_encryption_provisionable": False, "api_encryption_supported": False, "area": {"area_id": 0, "name": ""}, "areas": [], From 0d1af8cedb6644cf591189f1513538ae4808969b Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 10 Jul 2026 21:29:39 +0200 Subject: [PATCH 461/707] Portainer add text selectors (#176158) --- .../components/portainer/config_flow.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 3ec635cca45c..2037ab45cac9 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -18,15 +18,25 @@ from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + BooleanSelector, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_URL): str, - vol.Required(CONF_API_TOKEN): str, - vol.Optional(CONF_VERIFY_SSL, default=True): bool, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_API_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_VERIFY_SSL, default=True): BooleanSelector(), } ) @@ -126,7 +136,13 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + data_schema=vol.Schema( + { + vol.Required(CONF_API_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ) + } + ), errors=errors, ) From ad0aa9cb6eb44d060faf799ddaf57745ee49a0e1 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Fri, 10 Jul 2026 21:45:27 +0200 Subject: [PATCH 462/707] Add application credentials setup hint to ViCare (#175130) --- homeassistant/components/vicare/application_credentials.py | 7 +++++++ homeassistant/components/vicare/strings.json | 3 +++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/components/vicare/application_credentials.py b/homeassistant/components/vicare/application_credentials.py index 6d2773d504c6..4e794091ae0b 100644 --- a/homeassistant/components/vicare/application_credentials.py +++ b/homeassistant/components/vicare/application_credentials.py @@ -18,6 +18,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( VICARE_SCOPES = [SCOPE_IOT, SCOPE_OFFLINE_ACCESS] +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "more_info_url": "https://www.home-assistant.io/integrations/vicare/#prerequisites" + } + + async def async_get_auth_implementation( hass: HomeAssistant, auth_domain: str, credential: ClientCredential ) -> ViCareOAuth2Implementation: diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 92e2608b0a8d..a971d8d5d6a7 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "The **client secret** is ignored, enter any value. Follow the [setup instructions]({more_info_url}) to obtain the Client ID." + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", From b3789e8c9392dcf7c4c677af3b625e5dd9df09e8 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 10 Jul 2026 21:48:11 +0200 Subject: [PATCH 463/707] MELCloud Home optimize common entities (#176134) --- .../components/melcloud_home/binary_sensor.py | 98 ++--- .../components/melcloud_home/entity.py | 7 + .../components/melcloud_home/number.py | 394 +++++++----------- .../components/melcloud_home/sensor.py | 111 +++-- .../components/melcloud_home/switch.py | 185 ++++---- 5 files changed, 322 insertions(+), 473 deletions(-) diff --git a/homeassistant/components/melcloud_home/binary_sensor.py b/homeassistant/components/melcloud_home/binary_sensor.py index 2543227e106a..8fe5db3af505 100644 --- a/homeassistant/components/melcloud_home/binary_sensor.py +++ b/homeassistant/components/melcloud_home/binary_sensor.py @@ -22,34 +22,46 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class ATABinarySensorEntityDescription(BinarySensorEntityDescription): - """Class to hold MELCloud Home ATA binary sensor description.""" +class MelCloudHomeBinarySensorEntityDescription[_UnitT: ATAUnit | ATWUnit]( + BinarySensorEntityDescription +): + """Class to hold MELCloud Home binary sensor description.""" - state_fn: Callable[[ATAUnit], bool | None] + state_fn: Callable[[_UnitT], bool | None] -@dataclass(frozen=True, kw_only=True) -class ATWBinarySensorEntityDescription(BinarySensorEntityDescription): - """Class to hold MELCloud Home ATW binary sensor description.""" - - state_fn: Callable[[ATWUnit], bool | None] +def _common_sensor_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], +) -> tuple[MelCloudHomeBinarySensorEntityDescription[_UnitT], ...]: + """Return the binary sensor descriptions shared by ATA and ATW units.""" + return ( + MelCloudHomeBinarySensorEntityDescription( + key="error", + translation_key="error", + device_class=BinarySensorDeviceClass.PROBLEM, + state_fn=lambda unit: unit.is_in_error, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MelCloudHomeBinarySensorEntityDescription( + key="standby", + translation_key="standby", + state_fn=lambda unit: unit.in_standby_mode, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MelCloudHomeBinarySensorEntityDescription( + key="holiday_mode", + translation_key="holiday_mode", + state_fn=lambda unit: ( + unit.holiday_mode.enabled if unit.holiday_mode else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + ) -ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = ( - ATABinarySensorEntityDescription( - key="error", - translation_key="error", - device_class=BinarySensorDeviceClass.PROBLEM, - state_fn=lambda unit: unit.is_in_error, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATABinarySensorEntityDescription( - key="standby", - translation_key="standby", - state_fn=lambda unit: unit.in_standby_mode, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATABinarySensorEntityDescription( +ATA_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATAUnit], ...] = ( + *_common_sensor_descriptions(ATAUnit), + MelCloudHomeBinarySensorEntityDescription( key="frost_protection", translation_key="frost_protection", state_fn=lambda unit: ( @@ -57,7 +69,7 @@ ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = ( ), entity_category=EntityCategory.DIAGNOSTIC, ), - ATABinarySensorEntityDescription( + MelCloudHomeBinarySensorEntityDescription( key="overheat_protection", translation_key="overheat_protection", state_fn=lambda unit: ( @@ -65,40 +77,16 @@ ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = ( ), entity_category=EntityCategory.DIAGNOSTIC, ), - ATABinarySensorEntityDescription( - key="holiday_mode", - translation_key="holiday_mode", - state_fn=lambda unit: unit.holiday_mode.enabled if unit.holiday_mode else None, - entity_category=EntityCategory.DIAGNOSTIC, - ), ) -ATW_SENSORS: tuple[ATWBinarySensorEntityDescription, ...] = ( - ATWBinarySensorEntityDescription( - key="error", - translation_key="error", - device_class=BinarySensorDeviceClass.PROBLEM, - state_fn=lambda unit: unit.is_in_error, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATWBinarySensorEntityDescription( - key="standby", - translation_key="standby", - state_fn=lambda unit: unit.in_standby_mode, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATWBinarySensorEntityDescription( +ATW_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATWUnit], ...] = ( + *_common_sensor_descriptions(ATWUnit), + MelCloudHomeBinarySensorEntityDescription( key="forced_hot_water", translation_key="forced_hot_water", state_fn=lambda unit: unit.forced_hot_water_mode, entity_category=EntityCategory.DIAGNOSTIC, ), - ATWBinarySensorEntityDescription( - key="holiday_mode", - translation_key="holiday_mode", - state_fn=lambda unit: unit.holiday_mode.enabled if unit.holiday_mode else None, - entity_category=EntityCategory.DIAGNOSTIC, - ), ) @@ -134,12 +122,12 @@ async def async_setup_entry( class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity): """Representation of a MELCloud Home ATA binary sensor.""" - entity_description: ATABinarySensorEntityDescription + entity_description: MelCloudHomeBinarySensorEntityDescription[ATAUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATABinarySensorEntityDescription, + entity_description: MelCloudHomeBinarySensorEntityDescription[ATAUnit], unit: ATAUnit, ) -> None: """Initialize the entity.""" @@ -157,12 +145,12 @@ class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity): class ATWBinarySensor(MelCloudHomeATWUnitEntity, BinarySensorEntity): """Representation of a MELCloud Home ATW binary sensor.""" - entity_description: ATWBinarySensorEntityDescription + entity_description: MelCloudHomeBinarySensorEntityDescription[ATWUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATWBinarySensorEntityDescription, + entity_description: MelCloudHomeBinarySensorEntityDescription[ATWUnit], unit: ATWUnit, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/melcloud_home/entity.py b/homeassistant/components/melcloud_home/entity.py index da84d8e9abbe..79a797fe7335 100644 --- a/homeassistant/components/melcloud_home/entity.py +++ b/homeassistant/components/melcloud_home/entity.py @@ -13,6 +13,13 @@ from .const import DEVICE_ATA, DEVICE_ATW, DOMAIN, WEB_BASE_URL from .coordinator import MelCloudHomeCoordinator +def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]: + """Return the client keyword argument selecting this unit.""" + if isinstance(unit, ATAUnit): + return {"ata_unit_ids": [unit.id]} + return {"atw_unit_ids": [unit.id]} + + class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]): """Base entity for MELCloud Home.""" diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py index 65e2fbfd5868..bd2c89f83b3a 100644 --- a/homeassistant/components/melcloud_home/number.py +++ b/homeassistant/components/melcloud_home/number.py @@ -23,259 +23,161 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator -from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity, unit_ids PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class ATANumberEntityDescription(NumberEntityDescription): - """Class to hold MELCloud Home ATA number description.""" +class MelCloudHomeNumberEntityDescription[_UnitT: ATAUnit | ATWUnit]( + NumberEntityDescription +): + """Class to hold MELCloud Home number description.""" - available_fn: Callable[[ATAUnit], bool] - value_fn: Callable[[ATAUnit], float | None] - set_value_fn: Callable[[MELCloudHome, ATAUnit, float], Coroutine[Any, Any, None]] - validate_fn: Callable[[ATAUnit, float], str | None] | None = None + available_fn: Callable[[_UnitT], bool] + value_fn: Callable[[_UnitT], float | None] + set_value_fn: Callable[[MELCloudHome, _UnitT, float], Coroutine[Any, Any, None]] + validate_fn: Callable[[_UnitT, float], str | None] | None = None -@dataclass(frozen=True, kw_only=True) -class ATWNumberEntityDescription(NumberEntityDescription): - """Class to hold MELCloud Home ATW number description.""" - - available_fn: Callable[[ATWUnit], bool] - value_fn: Callable[[ATWUnit], float | None] - set_value_fn: Callable[[MELCloudHome, ATWUnit, float], Coroutine[Any, Any, None]] - validate_fn: Callable[[ATWUnit, float], str | None] | None = None +def _number_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], + *, + overheat_min_temp: float, + overheat_max_temp: float, +) -> tuple[MelCloudHomeNumberEntityDescription[_UnitT], ...]: + """Return the number descriptions for a unit type.""" + return ( + MelCloudHomeNumberEntityDescription( + key="frost_protection_min_temp", + translation_key="frost_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.min if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled + if unit.frost_protection + else False, + min_temp=value, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.frost_protection and value >= unit.frost_protection.max + else None + ), + ), + MelCloudHomeNumberEntityDescription( + key="frost_protection_max_temp", + translation_key="frost_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.max if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled + if unit.frost_protection + else False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=value, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.frost_protection and value <= unit.frost_protection.min + else None + ), + ), + MelCloudHomeNumberEntityDescription( + key="overheat_protection_min_temp", + translation_key="overheat_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=overheat_min_temp, + native_max_value=overheat_max_temp, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None + and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.min if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=value, + max_temp=unit.overheat_protection.max + if unit.overheat_protection + else 0.0, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.overheat_protection and value >= unit.overheat_protection.max + else None + ), + ), + MelCloudHomeNumberEntityDescription( + key="overheat_protection_max_temp", + translation_key="overheat_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=overheat_min_temp, + native_max_value=overheat_max_temp, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None + and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.max if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=unit.overheat_protection.min + if unit.overheat_protection + else 0.0, + max_temp=value, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.overheat_protection and value <= unit.overheat_protection.min + else None + ), + ), + ) -ATA_NUMBERS: tuple[ATANumberEntityDescription, ...] = ( - ATANumberEntityDescription( - key="frost_protection_min_temp", - translation_key="frost_protection_min_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=0.0, - native_max_value=30.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.frost_protection is not None and unit.frost_protection.enabled - ), - value_fn=lambda unit: ( - unit.frost_protection.min if unit.frost_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_frost_protection( - enabled=unit.frost_protection.enabled if unit.frost_protection else False, - min_temp=value, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - ata_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_min_exceeds_max" - if unit.frost_protection and value >= unit.frost_protection.max - else None - ), - ), - ATANumberEntityDescription( - key="frost_protection_max_temp", - translation_key="frost_protection_max_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=0.0, - native_max_value=30.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.frost_protection is not None and unit.frost_protection.enabled - ), - value_fn=lambda unit: ( - unit.frost_protection.max if unit.frost_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_frost_protection( - enabled=unit.frost_protection.enabled if unit.frost_protection else False, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=value, - ata_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_max_below_min" - if unit.frost_protection and value <= unit.frost_protection.min - else None - ), - ), - ATANumberEntityDescription( - key="overheat_protection_min_temp", - translation_key="overheat_protection_min_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=31.0, - native_max_value=40.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.overheat_protection is not None and unit.overheat_protection.enabled - ), - value_fn=lambda unit: ( - unit.overheat_protection.min if unit.overheat_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_overheat_protection( - enabled=unit.overheat_protection.enabled - if unit.overheat_protection - else False, - min_temp=value, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - ata_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_min_exceeds_max" - if unit.overheat_protection and value >= unit.overheat_protection.max - else None - ), - ), - ATANumberEntityDescription( - key="overheat_protection_max_temp", - translation_key="overheat_protection_max_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=31.0, - native_max_value=40.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.overheat_protection is not None and unit.overheat_protection.enabled - ), - value_fn=lambda unit: ( - unit.overheat_protection.max if unit.overheat_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_overheat_protection( - enabled=unit.overheat_protection.enabled - if unit.overheat_protection - else False, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=value, - ata_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_max_below_min" - if unit.overheat_protection and value <= unit.overheat_protection.min - else None - ), - ), +ATA_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATAUnit], ...] = ( + _number_descriptions(ATAUnit, overheat_min_temp=31.0, overheat_max_temp=40.0) ) - -ATW_NUMBERS: tuple[ATWNumberEntityDescription, ...] = ( - ATWNumberEntityDescription( - key="frost_protection_min_temp", - translation_key="frost_protection_min_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=0.0, - native_max_value=30.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.frost_protection is not None and unit.frost_protection.enabled - ), - value_fn=lambda unit: ( - unit.frost_protection.min if unit.frost_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_frost_protection( - enabled=unit.frost_protection.enabled if unit.frost_protection else False, - min_temp=value, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - atw_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_min_exceeds_max" - if unit.frost_protection and value >= unit.frost_protection.max - else None - ), - ), - ATWNumberEntityDescription( - key="frost_protection_max_temp", - translation_key="frost_protection_max_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=0.0, - native_max_value=30.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.frost_protection is not None and unit.frost_protection.enabled - ), - value_fn=lambda unit: ( - unit.frost_protection.max if unit.frost_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_frost_protection( - enabled=unit.frost_protection.enabled if unit.frost_protection else False, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=value, - atw_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_max_below_min" - if unit.frost_protection and value <= unit.frost_protection.min - else None - ), - ), - ATWNumberEntityDescription( - key="overheat_protection_min_temp", - translation_key="overheat_protection_min_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=20.0, - native_max_value=60.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.overheat_protection is not None and unit.overheat_protection.enabled - ), - value_fn=lambda unit: ( - unit.overheat_protection.min if unit.overheat_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_overheat_protection( - enabled=unit.overheat_protection.enabled - if unit.overheat_protection - else False, - min_temp=value, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - atw_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_min_exceeds_max" - if unit.overheat_protection and value >= unit.overheat_protection.max - else None - ), - ), - ATWNumberEntityDescription( - key="overheat_protection_max_temp", - translation_key="overheat_protection_max_temp", - device_class=NumberDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_category=EntityCategory.CONFIG, - native_min_value=20.0, - native_max_value=60.0, - native_step=0.5, - available_fn=lambda unit: ( - unit.overheat_protection is not None and unit.overheat_protection.enabled - ), - value_fn=lambda unit: ( - unit.overheat_protection.max if unit.overheat_protection else None - ), - set_value_fn=lambda client, unit, value: client.set_overheat_protection( - enabled=unit.overheat_protection.enabled - if unit.overheat_protection - else False, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=value, - atw_unit_ids=[unit.id], - ), - validate_fn=lambda unit, value: ( - "temperature_max_below_min" - if unit.overheat_protection and value <= unit.overheat_protection.min - else None - ), - ), +ATW_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATWUnit], ...] = ( + _number_descriptions(ATWUnit, overheat_min_temp=20.0, overheat_max_temp=60.0) ) @@ -337,12 +239,12 @@ async def async_setup_entry( class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): """Representation of a MELCloud Home ATA number.""" - entity_description: ATANumberEntityDescription + entity_description: MelCloudHomeNumberEntityDescription[ATAUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATANumberEntityDescription, + entity_description: MelCloudHomeNumberEntityDescription[ATAUnit], unit: ATAUnit, ) -> None: """Initialize the entity.""" @@ -383,12 +285,12 @@ class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): class ATWNumber(MelCloudHomeATWUnitEntity, NumberEntity): """Representation of a MELCloud Home ATW number.""" - entity_description: ATWNumberEntityDescription + entity_description: MelCloudHomeNumberEntityDescription[ATWUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATWNumberEntityDescription, + entity_description: MelCloudHomeNumberEntityDescription[ATWUnit], unit: ATWUnit, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 5f4f5e62c182..7dd295724841 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -31,23 +31,50 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class ATASensorEntityDescription(SensorEntityDescription): - """Class to hold MELCloud Home ATA sensor description.""" +class MelCloudHomeSensorEntityDescription[_UnitT: ATAUnit | ATWUnit]( + SensorEntityDescription +): + """Class to hold MELCloud Home sensor description.""" - value_fn: Callable[[ATAUnit, MelCloudHomeCoordinator], StateType] - exists_fn: Callable[[ATAUnit], bool] = lambda _: True + value_fn: Callable[[_UnitT, MelCloudHomeCoordinator], StateType] + exists_fn: Callable[[_UnitT], bool] = lambda _: True -@dataclass(frozen=True, kw_only=True) -class ATWSensorEntityDescription(SensorEntityDescription): - """Class to hold MELCloud Home ATW sensor description.""" - - value_fn: Callable[[ATWUnit, MelCloudHomeCoordinator], StateType] - exists_fn: Callable[[ATWUnit], bool] = lambda _: True +def _common_sensor_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], +) -> tuple[MelCloudHomeSensorEntityDescription[_UnitT], ...]: + """Return the sensor descriptions shared by ATA and ATW units.""" + return ( + MelCloudHomeSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda unit, _: unit.rssi, + ), + MelCloudHomeSensorEntityDescription( + key="energy_consumed", + translation_key="energy_consumed", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda unit, coordinator: ( + coordinator.ata_energy + if isinstance(unit, ATAUnit) + else coordinator.atw_energy + ).get(unit.id), + exists_fn=lambda unit: bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ), + ), + ) -ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = ( - ATASensorEntityDescription( +ATA_SENSORS: tuple[MelCloudHomeSensorEntityDescription[ATAUnit], ...] = ( + MelCloudHomeSensorEntityDescription( key="room_temperature", translation_key="room_temperature", device_class=SensorDeviceClass.TEMPERATURE, @@ -56,31 +83,11 @@ ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = ( suggested_display_precision=1, value_fn=lambda unit, _: unit.room_temperature, ), - ATASensorEntityDescription( - key="rssi", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - value_fn=lambda unit, _: unit.rssi, - ), - ATASensorEntityDescription( - key="energy_consumed", - translation_key="energy_consumed", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda unit, coordinator: coordinator.ata_energy.get(unit.id), - exists_fn=lambda unit: bool( - unit.capabilities and unit.capabilities.has_energy_consumed_meter - ), - ), + *_common_sensor_descriptions(ATAUnit), ) -ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( - ATWSensorEntityDescription( +ATW_SENSORS: tuple[MelCloudHomeSensorEntityDescription[ATWUnit], ...] = ( + MelCloudHomeSensorEntityDescription( key="room_temperature_zone_1", translation_key="room_temperature_zone_1", device_class=SensorDeviceClass.TEMPERATURE, @@ -89,7 +96,7 @@ ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( suggested_display_precision=1, value_fn=lambda unit, _: unit.room_temperature_zone1, ), - ATWSensorEntityDescription( + MelCloudHomeSensorEntityDescription( key="room_temperature_zone_2", translation_key="room_temperature_zone_2", device_class=SensorDeviceClass.TEMPERATURE, @@ -102,7 +109,7 @@ ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( or (unit.capabilities is None and unit.has_zone2) ), ), - ATWSensorEntityDescription( + MelCloudHomeSensorEntityDescription( key="tank_water_temperature", translation_key="tank_water_temperature", device_class=SensorDeviceClass.TEMPERATURE, @@ -111,27 +118,7 @@ ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( suggested_display_precision=1, value_fn=lambda unit, _: unit.tank_water_temperature, ), - ATWSensorEntityDescription( - key="rssi", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - value_fn=lambda unit, _: unit.rssi, - ), - ATWSensorEntityDescription( - key="energy_consumed", - translation_key="energy_consumed", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda unit, coordinator: coordinator.atw_energy.get(unit.id), - exists_fn=lambda unit: bool( - unit.capabilities and unit.capabilities.has_energy_consumed_meter - ), - ), + *_common_sensor_descriptions(ATWUnit), ) @@ -169,12 +156,12 @@ async def async_setup_entry( class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity): """Representation of a MELCloud Home ATA sensor.""" - entity_description: ATASensorEntityDescription + entity_description: MelCloudHomeSensorEntityDescription[ATAUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATASensorEntityDescription, + entity_description: MelCloudHomeSensorEntityDescription[ATAUnit], unit: ATAUnit, ) -> None: """Initialize the entity.""" @@ -200,12 +187,12 @@ class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity): class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity): """Representation of a MELCloud Home ATW sensor.""" - entity_description: ATWSensorEntityDescription + entity_description: MelCloudHomeSensorEntityDescription[ATWUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATWSensorEntityDescription, + entity_description: MelCloudHomeSensorEntityDescription[ATWUnit], unit: ATWUnit, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py index 75301cc5ddad..b209d6e3bb59 100644 --- a/homeassistant/components/melcloud_home/switch.py +++ b/homeassistant/components/melcloud_home/switch.py @@ -23,123 +23,88 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator -from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity, unit_ids PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class ATASwitchEntityDescription(SwitchEntityDescription): - """Class to hold MELCloud Home ATA switch description.""" +class MelCloudHomeSwitchEntityDescription[_UnitT: ATAUnit | ATWUnit]( + SwitchEntityDescription +): + """Class to hold MELCloud Home switch description.""" - available_fn: Callable[[ATAUnit], bool] - is_on_fn: Callable[[ATAUnit], bool | None] - turn_on_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]] + available_fn: Callable[[_UnitT], bool] + is_on_fn: Callable[[_UnitT], bool | None] + turn_on_fn: Callable[[MELCloudHome, _UnitT], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[MELCloudHome, _UnitT], Coroutine[Any, Any, None]] -@dataclass(frozen=True, kw_only=True) -class ATWSwitchEntityDescription(SwitchEntityDescription): - """Class to hold MELCloud Home ATW switch description.""" - - available_fn: Callable[[ATWUnit], bool] - is_on_fn: Callable[[ATWUnit], bool | None] - turn_on_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]] +def _switch_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], +) -> tuple[MelCloudHomeSwitchEntityDescription[_UnitT], ...]: + """Return the switch descriptions for a unit type.""" + return ( + MelCloudHomeSwitchEntityDescription( + key="frost_protection", + translation_key="frost_protection", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + available_fn=lambda unit: unit.frost_protection is not None, + is_on_fn=lambda unit: ( + unit.frost_protection.enabled if unit.frost_protection else None + ), + turn_on_fn=lambda client, unit: client.set_frost_protection( + enabled=True, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + **unit_ids(unit), + ), + turn_off_fn=lambda client, unit: client.set_frost_protection( + enabled=False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + **unit_ids(unit), + ), + ), + MelCloudHomeSwitchEntityDescription( + key="overheat_protection", + translation_key="overheat_protection", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + available_fn=lambda unit: unit.overheat_protection is not None, + is_on_fn=lambda unit: ( + unit.overheat_protection.enabled if unit.overheat_protection else None + ), + turn_on_fn=lambda client, unit: client.set_overheat_protection( + enabled=True, + min_temp=unit.overheat_protection.min + if unit.overheat_protection + else 0.0, + max_temp=unit.overheat_protection.max + if unit.overheat_protection + else 0.0, + **unit_ids(unit), + ), + turn_off_fn=lambda client, unit: client.set_overheat_protection( + enabled=False, + min_temp=unit.overheat_protection.min + if unit.overheat_protection + else 0.0, + max_temp=unit.overheat_protection.max + if unit.overheat_protection + else 0.0, + **unit_ids(unit), + ), + ), + ) -ATA_SWITCHES: tuple[ATASwitchEntityDescription, ...] = ( - ATASwitchEntityDescription( - key="frost_protection", - translation_key="frost_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.frost_protection is not None, - is_on_fn=lambda unit: ( - unit.frost_protection.enabled if unit.frost_protection else None - ), - turn_on_fn=lambda client, unit: client.set_frost_protection( - enabled=True, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - ata_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_frost_protection( - enabled=False, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - ata_unit_ids=[unit.id], - ), - ), - ATASwitchEntityDescription( - key="overheat_protection", - translation_key="overheat_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.overheat_protection is not None, - is_on_fn=lambda unit: ( - unit.overheat_protection.enabled if unit.overheat_protection else None - ), - turn_on_fn=lambda client, unit: client.set_overheat_protection( - enabled=True, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - ata_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_overheat_protection( - enabled=False, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - ata_unit_ids=[unit.id], - ), - ), +ATA_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATAUnit], ...] = ( + _switch_descriptions(ATAUnit) ) - -ATW_SWITCHES: tuple[ATWSwitchEntityDescription, ...] = ( - ATWSwitchEntityDescription( - key="frost_protection", - translation_key="frost_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.frost_protection is not None, - is_on_fn=lambda unit: ( - unit.frost_protection.enabled if unit.frost_protection else None - ), - turn_on_fn=lambda client, unit: client.set_frost_protection( - enabled=True, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - atw_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_frost_protection( - enabled=False, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - atw_unit_ids=[unit.id], - ), - ), - ATWSwitchEntityDescription( - key="overheat_protection", - translation_key="overheat_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.overheat_protection is not None, - is_on_fn=lambda unit: ( - unit.overheat_protection.enabled if unit.overheat_protection else None - ), - turn_on_fn=lambda client, unit: client.set_overheat_protection( - enabled=True, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - atw_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_overheat_protection( - enabled=False, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - atw_unit_ids=[unit.id], - ), - ), +ATW_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATWUnit], ...] = ( + _switch_descriptions(ATWUnit) ) @@ -201,12 +166,12 @@ async def async_setup_entry( class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): """Representation of a MELCloud Home ATA switch.""" - entity_description: ATASwitchEntityDescription + entity_description: MelCloudHomeSwitchEntityDescription[ATAUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATASwitchEntityDescription, + entity_description: MelCloudHomeSwitchEntityDescription[ATAUnit], unit: ATAUnit, ) -> None: """Initialize the entity.""" @@ -246,12 +211,12 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): """Representation of a MELCloud Home ATW switch.""" - entity_description: ATWSwitchEntityDescription + entity_description: MelCloudHomeSwitchEntityDescription[ATWUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATWSwitchEntityDescription, + entity_description: MelCloudHomeSwitchEntityDescription[ATWUnit], unit: ATWUnit, ) -> None: """Initialize the entity.""" From 79657129a44bff3ed28afcfd13c14790d939ac1a Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 10 Jul 2026 21:49:11 +0200 Subject: [PATCH 464/707] MELCloud Home refactor/optimize setup path (#176131) --- .../components/melcloud_home/binary_sensor.py | 27 +++++++-------- .../components/melcloud_home/climate.py | 24 +++++--------- .../components/melcloud_home/common.py | 33 +++++++++++++++++++ .../components/melcloud_home/number.py | 27 +++++++-------- .../components/melcloud_home/sensor.py | 27 +++++++-------- .../components/melcloud_home/switch.py | 27 +++++++-------- 6 files changed, 86 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/melcloud_home/common.py diff --git a/homeassistant/components/melcloud_home/binary_sensor.py b/homeassistant/components/melcloud_home/binary_sensor.py index 8fe5db3af505..1ed70ef82c76 100644 --- a/homeassistant/components/melcloud_home/binary_sensor.py +++ b/homeassistant/components/melcloud_home/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .common import async_setup_unit_entities from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -96,27 +97,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home binary sensors.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities( - ATABinarySensor(coordinator, entity_description, unit) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATABinarySensor(entry.runtime_data, entity_description, unit) for entity_description in ATA_SENSORS for unit in units - ) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWBinarySensor(coordinator, entity_description, unit) + ), + lambda units: ( + ATWBinarySensor(entry.runtime_data, entity_description, unit) for entity_description in ATW_SENSORS for unit in units - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity): diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py index c4fc175d6892..f7742e6b3bc6 100644 --- a/homeassistant/components/melcloud_home/climate.py +++ b/homeassistant/components/melcloud_home/climate.py @@ -8,7 +8,6 @@ from aiomelcloudhome import ( ATAUnit, ATAVaneHorizontal, ATAVaneVertical, - ATWUnit, ATWZoneMode, ) @@ -21,6 +20,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .common import async_setup_unit_entities from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity @@ -99,14 +99,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home climate entities from a config entry.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWZoneClimateEntity(coordinator, unit, zone_number) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: (ATAClimateEntity(entry.runtime_data, unit) for unit in units), + lambda units: ( + ATWZoneClimateEntity(entry.runtime_data, unit, zone_number) for unit in units for zone_number in ( [1, 2] @@ -114,13 +113,8 @@ async def async_setup_entry( or (unit.capabilities is None and unit.has_zone2) else [1] ) - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity): diff --git a/homeassistant/components/melcloud_home/common.py b/homeassistant/components/melcloud_home/common.py new file mode 100644 index 000000000000..84cb3bb3f8c2 --- /dev/null +++ b/homeassistant/components/melcloud_home/common.py @@ -0,0 +1,33 @@ +"""Commonly shared code for the MELCloud Home integration.""" + +from collections.abc import Callable, Iterable + +from aiomelcloudhome import ATAUnit, ATWUnit + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MelCloudHomeCoordinator + + +@callback +def async_setup_unit_entities( + coordinator: MelCloudHomeCoordinator, + async_add_entities: AddConfigEntryEntitiesCallback, + ata_entities_fn: Callable[[list[ATAUnit]], Iterable[Entity]], + atw_entities_fn: Callable[[list[ATWUnit]], Iterable[Entity]], +) -> None: + """Add entities for the current units and register callbacks for new units.""" + + def _async_add_new_ata_units(units: list[ATAUnit]) -> None: + async_add_entities(ata_entities_fn(units)) + + def _async_add_new_atw_units(units: list[ATWUnit]) -> None: + async_add_entities(atw_entities_fn(units)) + + coordinator.new_ata_callbacks.append(_async_add_new_ata_units) + coordinator.new_atw_callbacks.append(_async_add_new_atw_units) + + _async_add_new_ata_units(list(coordinator.ata_units.values())) + _async_add_new_atw_units(list(coordinator.atw_units.values())) diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py index bd2c89f83b3a..fcc42867328f 100644 --- a/homeassistant/components/melcloud_home/number.py +++ b/homeassistant/components/melcloud_home/number.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .common import async_setup_unit_entities from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity, unit_ids @@ -213,27 +214,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home numbers.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities( - ATANumber(coordinator, entity_description, unit) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATANumber(entry.runtime_data, entity_description, unit) for entity_description in ATA_NUMBERS for unit in units - ) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWNumber(coordinator, entity_description, unit) + ), + lambda units: ( + ATWNumber(entry.runtime_data, entity_description, unit) for entity_description in ATW_NUMBERS for unit in units - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 7dd295724841..5593ac3725cf 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow +from .common import async_setup_unit_entities from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -128,29 +129,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home sensors.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities( - ATASensor(coordinator, entity_description, unit) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATASensor(entry.runtime_data, entity_description, unit) for entity_description in ATA_SENSORS for unit in units if entity_description.exists_fn(unit) - ) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWSensor(coordinator, entity_description, unit) + ), + lambda units: ( + ATWSensor(entry.runtime_data, entity_description, unit) for entity_description in ATW_SENSORS for unit in units if entity_description.exists_fn(unit) - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity): diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py index b209d6e3bb59..1d0d8062f526 100644 --- a/homeassistant/components/melcloud_home/switch.py +++ b/homeassistant/components/melcloud_home/switch.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .common import async_setup_unit_entities from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity, unit_ids @@ -140,27 +141,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home switches.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities( - ATASwitch(coordinator, entity_description, unit) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATASwitch(entry.runtime_data, entity_description, unit) for entity_description in ATA_SWITCHES for unit in units - ) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWSwitch(coordinator, entity_description, unit) + ), + lambda units: ( + ATWSwitch(entry.runtime_data, entity_description, unit) for entity_description in ATW_SWITCHES for unit in units - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): From ab4ab8386f58e9914654966768dba237c4640e51 Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:49:38 +0800 Subject: [PATCH 465/707] Raise ConfigEntryNotReady when Tuya setup hits a network error (#176124) --- homeassistant/components/tuya/coordinator.py | 6 +++++- tests/components/tuya/test_init.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py index 7579b803286e..ba3f7115438e 100644 --- a/homeassistant/components/tuya/coordinator.py +++ b/homeassistant/components/tuya/coordinator.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Any +import requests from tuya_device_handlers import TUYA_QUIRKS_REGISTRY from tuya_device_handlers.devices import register_tuya_quirks from tuya_sharing import ( @@ -14,7 +15,7 @@ from tuya_sharing import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send @@ -80,6 +81,9 @@ class DeviceListener(SharingDeviceListener): # Get all devices from Tuya, makes blocking web calls try: manager.update_device_cache() + except requests.exceptions.ConnectionError as exc: + msg = "Unable to connect to Tuya" + raise ConfigEntryNotReady(msg) from exc except Exception as exc: # While in general, we should avoid catching broad exceptions, # we have no other way of detecting this case. diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index ca68b7ad941e..817ac8311f20 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest +import requests from syrupy.assertion import SnapshotAssertion from tuya_device_handlers import TUYA_QUIRKS_REGISTRY from tuya_sharing import CustomerDevice, Manager @@ -15,6 +16,7 @@ from homeassistant.components.tuya.const import ( DOMAIN, ) from homeassistant.components.tuya.diagnostics import _REDACTED_DPCODES +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -369,3 +371,18 @@ async def test_fixtures_valid(hass: HomeAssistant) -> None: f"Please mark `data['status']['{key}']` as `**REDACTED**`" f" in {device_code}.json" ) + + +async def test_network_error_retries_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a network error during setup results in a setup retry.""" + manager = create_manager() + manager.update_device_cache.side_effect = requests.exceptions.ConnectionError( + "Failed to resolve 'apigw.tuyaeu.com'" + ) + + await initialize_entry(hass, manager, mock_config_entry, []) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 4b29091f44c8c148d99b6cdef97a4049cfd30d64 Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:50:17 +0800 Subject: [PATCH 466/707] Abort linkplay zeroconf flow before probing when UUID is already known (#176123) --- .../components/linkplay/config_flow.py | 11 ++++ tests/components/linkplay/conftest.py | 2 +- tests/components/linkplay/test_config_flow.py | 58 ++++++++++++++++++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index fc2a9157e65e..acef5fec0d46 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -36,6 +36,17 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: discovery_info.host}) + # Do not probe the device if the UUID advertised over mDNS matches + # an existing (or ignored) entry + if uuid := discovery_info.properties.get("uuid"): + # The advertised UUID is prefixed and dashed + # (uuid:FF31F09E-5001-...), while the device API (and therefore + # the stored unique id) uses the dashless form + await self.async_set_unique_id(uuid.removeprefix("uuid:").replace("-", "")) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.host} + ) + session: ClientSession = await async_get_client_session(self.hass) bridge: LinkPlayBridge | None = None diff --git a/tests/components/linkplay/conftest.py b/tests/components/linkplay/conftest.py index b0b683908109..58399d853e8c 100644 --- a/tests/components/linkplay/conftest.py +++ b/tests/components/linkplay/conftest.py @@ -19,7 +19,7 @@ from tests.conftest import AiohttpClientMocker HOST = "10.0.0.150" HOST_REENTRY = "10.0.0.66" -UUID = "FF31F09E-5001-FBDE-0546-2DBFFF31F09E" +UUID = "FF31F09E5001FBDE05462DBFFF31F09E" NAME = "Smart Zone 1_54B9" diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index 17c3534f3429..3bdddff90fdf 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -8,7 +8,7 @@ from linkplay.manufacturers import MANUFACTURER_WIIM import pytest from homeassistant.components.linkplay.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -26,7 +26,7 @@ ZEROCONF_DISCOVERY = ZeroconfServiceInfo( port=59152, type="_linkplay._tcp.local.", properties={ - "uuid": f"uuid:{UUID}", + "uuid": "uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E", "mac": "00:2F:69:01:84:3A", "security": "https 2.0", "upnp": "1.0.0", @@ -42,7 +42,7 @@ ZEROCONF_DISCOVERY_RE_ENTRY = ZeroconfServiceInfo( port=59152, type="_linkplay._tcp.local.", properties={ - "uuid": f"uuid:{UUID}", + "uuid": "uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E", "mac": "00:2F:69:01:84:3A", "security": "https 2.0", "upnp": "1.0.0", @@ -134,6 +134,58 @@ async def test_zeroconf_flow( assert result["result"].unique_id == UUID +async def test_zeroconf_flow_ignored_entry( + hass: HomeAssistant, + mock_linkplay_factory_bridge: AsyncMock, +) -> None: + """Test Zeroconf discovery does not probe a device with an ignored entry.""" + + entry = MockConfigEntry( + data={}, + domain=DOMAIN, + title=NAME, + unique_id=UUID, + source=SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_linkplay_factory_bridge.assert_not_called() + + +async def test_zeroconf_flow_same_uuid_does_not_probe( + hass: HomeAssistant, + mock_linkplay_factory_bridge: AsyncMock, +) -> None: + """Test Zeroconf discovery aborts before probing when the UUID matches.""" + + entry = MockConfigEntry( + data={CONF_HOST: HOST}, + domain=DOMAIN, + title=NAME, + unique_id=UUID, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY_RE_ENTRY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == HOST_REENTRY + mock_linkplay_factory_bridge.assert_not_called() + + @pytest.mark.usefixtures("mock_linkplay_factory_bridge") async def test_zeroconf_flow_re_entry( hass: HomeAssistant, From 8a6d449c8e0c49d369cb4945ce39e1f61f5e6875 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Fri, 10 Jul 2026 20:53:32 +0100 Subject: [PATCH 467/707] Add Lyngdorf processor integration (#161948) Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 2 + homeassistant/components/lyngdorf/__init__.py | 130 +++++++ .../components/lyngdorf/config_flow.py | 175 +++++++++ homeassistant/components/lyngdorf/const.py | 11 + homeassistant/components/lyngdorf/entity.py | 46 +++ .../components/lyngdorf/manifest.json | 19 + .../components/lyngdorf/media_player.py | 288 ++++++++++++++ homeassistant/components/lyngdorf/models.py | 20 + .../components/lyngdorf/quality_scale.yaml | 92 +++++ .../components/lyngdorf/strings.json | 53 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/ssdp.py | 6 + requirements_all.txt | 3 + tests/components/lyngdorf/__init__.py | 1 + tests/components/lyngdorf/conftest.py | 114 ++++++ .../lyngdorf/snapshots/test_media_player.ambr | 107 +++++ tests/components/lyngdorf/test_config_flow.py | 367 ++++++++++++++++++ tests/components/lyngdorf/test_init.py | 112 ++++++ .../components/lyngdorf/test_media_player.py | 301 ++++++++++++++ 20 files changed, 1854 insertions(+) create mode 100644 homeassistant/components/lyngdorf/__init__.py create mode 100644 homeassistant/components/lyngdorf/config_flow.py create mode 100644 homeassistant/components/lyngdorf/const.py create mode 100644 homeassistant/components/lyngdorf/entity.py create mode 100644 homeassistant/components/lyngdorf/manifest.json create mode 100644 homeassistant/components/lyngdorf/media_player.py create mode 100644 homeassistant/components/lyngdorf/models.py create mode 100644 homeassistant/components/lyngdorf/quality_scale.yaml create mode 100644 homeassistant/components/lyngdorf/strings.json create mode 100644 tests/components/lyngdorf/__init__.py create mode 100644 tests/components/lyngdorf/conftest.py create mode 100644 tests/components/lyngdorf/snapshots/test_media_player.ambr create mode 100644 tests/components/lyngdorf/test_config_flow.py create mode 100644 tests/components/lyngdorf/test_init.py create mode 100644 tests/components/lyngdorf/test_media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index dc40b4e6f35a..6c01218ecb1a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1068,6 +1068,8 @@ CLAUDE.md @home-assistant/core /tests/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron_caseta/ @swails @danaues @eclair4151 /tests/components/lutron_caseta/ @swails @danaues @eclair4151 +/homeassistant/components/lyngdorf/ @fishloa +/tests/components/lyngdorf/ @fishloa /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/madvr/ @iloveicedgreentea diff --git a/homeassistant/components/lyngdorf/__init__.py b/homeassistant/components/lyngdorf/__init__.py new file mode 100644 index 000000000000..56d8b3db398e --- /dev/null +++ b/homeassistant/components/lyngdorf/__init__.py @@ -0,0 +1,130 @@ +"""The Lyngdorf integration.""" + +import logging + +from lyngdorf.device import async_create_receiver, lookup_receiver_model + +from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) + +from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS +from .models import LyngdorfConfigEntry, LyngdorfRuntimeData + +_LOGGER = logging.getLogger(__name__) + + +def _serial_as_mac(serial: str) -> str | None: + """Return a normalized MAC if the serial is one, otherwise None. + + Lyngdorf reports the device MAC in the UPnP serialNumber field, but this is + not formally guaranteed — fall back gracefully if the value is not a MAC. + """ + cleaned = serial.replace(":", "").replace("-", "").replace(".", "") + if len(cleaned) != 12 or not all(c in "0123456789abcdefABCDEF" for c in cleaned): + return None + return format_mac(cleaned) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: LyngdorfConfigEntry +) -> bool: + """Set up Lyngdorf from a config entry.""" + lyngdorf_model = lookup_receiver_model(config_entry.data[CONF_MODEL]) + assert lyngdorf_model is not None + + try: + receiver = await async_create_receiver( + config_entry.data[CONF_HOST], lyngdorf_model + ) + await receiver.async_connect() + except TimeoutError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_timeout", + translation_placeholders={"host": config_entry.data[CONF_HOST]}, + ) from err + except (ConnectionError, OSError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_connection_error", + translation_placeholders={"host": config_entry.data[CONF_HOST]}, + ) from err + + assert config_entry.unique_id + serial = config_entry.data[CONF_SERIAL_NUMBER] + mac = _serial_as_mac(serial) + connections = {(CONNECTION_NETWORK_MAC, mac)} if mac else set() + + device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.unique_id)}, + connections=connections, + manufacturer=lyngdorf_model.manufacturer, + serial_number=serial, + model=lyngdorf_model.model_name, + ) + + zone_b_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{config_entry.unique_id}_zone_b")}, + manufacturer=lyngdorf_model.manufacturer, + serial_number=serial, + model=lyngdorf_model.model_name, + translation_key="zone_b", + translation_placeholders={"device_name": config_entry.title}, + via_device=(DOMAIN, config_entry.unique_id), + ) + + config_entry.runtime_data = LyngdorfRuntimeData( + receiver=receiver, + device_info=device_info, + zone_b_device_info=zone_b_device_info, + ) + + host = config_entry.data[CONF_HOST] + last_connected = receiver.connected + + @callback + def _log_availability_change() -> None: + nonlocal last_connected + connected = receiver.connected + if connected == last_connected: + return + last_connected = connected + if connected: + _LOGGER.info("Lyngdorf %s is back online", host) + else: + _LOGGER.info("Lyngdorf %s is unavailable", host) + + receiver.register_notification_callback(_log_availability_change) + config_entry.async_on_unload( + lambda: receiver.un_register_notification_callback(_log_availability_change) + ) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + async def _async_disconnect(event: Event) -> None: + """Disconnect from receiver.""" + await receiver.async_disconnect() + + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: LyngdorfConfigEntry +) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + if unload_ok: + await config_entry.runtime_data.receiver.async_disconnect() + return unload_ok diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py new file mode 100644 index 000000000000..2ebd56213690 --- /dev/null +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -0,0 +1,175 @@ +"""Config flow for Lyngdorf integration.""" + +from typing import Any, override +from urllib.parse import urlparse + +from lyngdorf.device import ( + async_find_receiver_model, + async_get_device_serial, + lookup_receiver_model, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) + +from .const import CONF_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN + + +class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Lyngdorf config flow.""" + + def __init__(self) -> None: + """Initialize flow.""" + self._location: str | None = None + self._device_model: str | None = None + self._device_serial_number: str | None = None + self._name: str | None = None + self._host: str | None = None + + @property + def _display_name(self) -> str: + """Return the name shown to the user during discovery confirmation.""" + if self._device_model and self._device_model != self._name: + return f"{self._device_model} ({self._name})" + return self._name or DEFAULT_DEVICE_NAME + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._host = user_input[CONF_HOST] + + try: + model = await async_find_receiver_model(self._host) + except TimeoutError: + errors["base"] = "timeout_connect" + except OSError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + + if not errors and not model: + errors["base"] = "unsupported_model" + + if not errors and model: + self._device_model = model.model_name + self._name = model.model_name + + serial = await async_get_device_serial(self._host) + if not serial: + errors["base"] = "cannot_determine_id" + else: + self._device_serial_number = serial.lower() + await self.async_set_unique_id(self._device_serial_number) + self._abort_if_unique_id_configured() + return await self._create_entry() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } + ), + errors=errors, + ) + + @override + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by SSDP discovery.""" + await self._async_set_info_from_discovery(discovery_info) + + assert self._host + try: + model = await async_find_receiver_model(self._host) + except TimeoutError, OSError: + return self.async_abort(reason="cannot_connect") + if not model: + return self.async_abort(reason="unsupported_model") + + self.context["title_placeholders"] = {"name": self._display_name} + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + return await self._create_entry() + + self._set_confirm_only() + return self.async_show_form( + step_id="confirm", + description_placeholders={"name": self._display_name}, + ) + + async def _create_entry(self) -> ConfigFlowResult: + """Create a config entry.""" + assert self._host + assert self._device_model + if self._location: + title = ( + self._name or urlparse(self._location).hostname or DEFAULT_DEVICE_NAME + ) + else: + title = self._name or DEFAULT_DEVICE_NAME + + data: dict[str, Any] = { + CONF_MODEL: self._device_model, + CONF_HOST: self._host, + } + if self._device_serial_number: + data[CONF_SERIAL_NUMBER] = self._device_serial_number + + return self.async_create_entry(title=title, data=data) + + async def _async_set_info_from_discovery( + self, discovery_info: SsdpServiceInfo + ) -> None: + """Set information required for a config entry from SSDP discovery.""" + if not self._location: + self._location = discovery_info.ssdp_location + if not isinstance(self._location, str): + raise AbortFlow("cannot_connect") + + if hostname := ( + discovery_info.ssdp_headers.get("_host") + or urlparse(self._location).hostname + ): + self._host = str(hostname) + else: + raise AbortFlow("cannot_connect") + + device_model_name = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" + if not (model := lookup_receiver_model(device_model_name)): + raise AbortFlow("unsupported_model") + self._device_model = model.model_name + self._device_serial_number = ( + discovery_info.upnp.get(ATTR_UPNP_SERIAL) or "" + ).lower() or None + self._name = ( + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or urlparse(self._location).hostname + or DEFAULT_DEVICE_NAME + ) + + if not self._device_serial_number: + raise AbortFlow("cannot_determine_id") + await self.async_set_unique_id(self._device_serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py new file mode 100644 index 000000000000..d34961be3d5f --- /dev/null +++ b/homeassistant/components/lyngdorf/const.py @@ -0,0 +1,11 @@ +"""Constants for the Lyngdorf integration.""" + +from homeassistant.const import Platform + +DOMAIN = "lyngdorf" +DEFAULT_DEVICE_NAME = "Lyngdorf" + +PLATFORMS: list[Platform] = [ + Platform.MEDIA_PLAYER, +] +CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/lyngdorf/entity.py b/homeassistant/components/lyngdorf/entity.py new file mode 100644 index 000000000000..4c95d27e86d1 --- /dev/null +++ b/homeassistant/components/lyngdorf/entity.py @@ -0,0 +1,46 @@ +"""Base entity for Lyngdorf integration.""" + +from typing import override + +from lyngdorf.device import Receiver + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + + +class LyngdorfEntity(Entity): + """Base Lyngdorf entity.""" + + _attr_has_entity_name = True + _attr_available = True + _attr_should_poll = False + + def __init__(self, receiver: Receiver, device_info: DeviceInfo) -> None: + """Initialize the entity.""" + self._receiver = receiver + self._attr_device_info = device_info + + @override + async def async_added_to_hass(self) -> None: + """Register notification callback when added to hass.""" + await super().async_added_to_hass() + self._receiver.register_notification_callback(self._handle_receiver_update) + self._update_availability() + + @override + async def async_will_remove_from_hass(self) -> None: + """Unregister notification callback when removed from hass.""" + await super().async_will_remove_from_hass() + self._receiver.un_register_notification_callback(self._handle_receiver_update) + + @callback + def _handle_receiver_update(self) -> None: + """Handle receiver updates.""" + self._update_availability() + self.async_write_ha_state() + + @callback + def _update_availability(self) -> None: + """Update availability from receiver connection status.""" + self._attr_available = self._receiver.connected diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json new file mode 100644 index 000000000000..c4144acf1011 --- /dev/null +++ b/homeassistant/components/lyngdorf/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "lyngdorf", + "name": "Lyngdorf", + "codeowners": ["@fishloa"], + "config_flow": true, + "dependencies": ["ssdp"], + "documentation": "https://www.home-assistant.io/integrations/lyngdorf", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["lyngdorf", "async_upnp_client"], + "quality_scale": "silver", + "requirements": ["lyngdorf==1.3.3"], + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Lyngdorf" + } + ] +} diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py new file mode 100644 index 000000000000..dd764c9d0fec --- /dev/null +++ b/homeassistant/components/lyngdorf/media_player.py @@ -0,0 +1,288 @@ +"""Media player platform for Lyngdorf integration.""" + +from typing import override + +from lyngdorf.device import Receiver + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import LyngdorfEntity +from .models import LyngdorfConfigEntry + +PARALLEL_UPDATES = 1 + +MAX_VOLUME_DB = 18.0 +MIN_VOLUME_DB = -80.0 +VOLUME_RANGE = MAX_VOLUME_DB - MIN_VOLUME_DB + +FEATURES_ZONE_B = ( + MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE +) + +FEATURES_MAIN = ( + MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOUND_MODE + | MediaPlayerEntityFeature.SELECT_SOURCE +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LyngdorfConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the receiver from a config entry.""" + runtime_data = config_entry.runtime_data + + async_add_entities( + [ + LyngdorfMainDevice( + runtime_data.receiver, config_entry, runtime_data.device_info + ), + LyngdorfZoneBDevice( + runtime_data.receiver, config_entry, runtime_data.zone_b_device_info + ), + ] + ) + + +def _to_ha_volume(volume_db: float) -> float: + """Convert Lyngdorf dB volume to HA 0..1 scale, clamped to 0..1.""" + volume = (volume_db - MIN_VOLUME_DB) / VOLUME_RANGE + return max(0.0, min(volume, 1.0)) + + +def _to_lyngdorf_volume(volume: float) -> float: + """Convert HA 0..1 volume to Lyngdorf dB scale, clamped to min and max.""" + volume_db = volume * VOLUME_RANGE + MIN_VOLUME_DB + return max(MIN_VOLUME_DB, min(volume_db, MAX_VOLUME_DB)) + + +class LyngdorfDevice(LyngdorfEntity, MediaPlayerEntity): + """Base Lyngdorf media player entity.""" + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + translation_key: str | None, + entity_id_suffix: str, + features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0), + ) -> None: + """Initialize the device.""" + super().__init__(receiver, device_info) + assert config_entry.unique_id + self._attr_unique_id = f"{config_entry.unique_id}_{entity_id_suffix}" + self._attr_translation_key = translation_key + self._attr_supported_features = features + + +class LyngdorfZoneBDevice(LyngdorfDevice): + """Lyngdorf Zone B device.""" + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + ) -> None: + """Create the device.""" + super().__init__( + receiver, + config_entry, + device_info, + None, + "zone_b", + FEATURES_ZONE_B, + ) + + @override + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the device.""" + if self._receiver.zone_b_power_on: + return MediaPlayerState.ON + return MediaPlayerState.OFF + + @override + @property + def is_volume_muted(self) -> bool | None: + """Return boolean if volume is currently muted.""" + return self._receiver.zone_b_mute_enabled + + @override + @property + def volume_level(self) -> float | None: + """Volume level of the media player (0..1).""" + if not isinstance(self._receiver.zone_b_volume, float): + return None + return _to_ha_volume(self._receiver.zone_b_volume) + + @override + def turn_on(self) -> None: + """Turn on media player.""" + self._receiver.zone_b_power_on = True + + @override + def turn_off(self) -> None: + """Turn off media player.""" + self._receiver.zone_b_power_on = False + + def volume_up(self) -> None: + """Volume up the media player.""" + self._receiver.zone_b_volume_up() + + def volume_down(self) -> None: + """Volume down the media player.""" + self._receiver.zone_b_volume_down() + + @override + def set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self._receiver.zone_b_volume = _to_lyngdorf_volume(volume) + + @override + def mute_volume(self, mute: bool) -> None: + """Send mute command.""" + self._receiver.zone_b_mute_enabled = mute + + @override + @property + def source(self) -> str | None: + """Return the current input source.""" + return self._receiver.zone_b_source + + @override + @property + def source_list(self) -> list[str] | None: + """Return the list of available sources.""" + return self._receiver.zone_b_available_sources + + @override + def select_source(self, source: str) -> None: + """Select input source.""" + self._receiver.zone_b_source = source + + +class LyngdorfMainDevice(LyngdorfDevice): + """Lyngdorf main zone device.""" + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + ) -> None: + """Create the device.""" + super().__init__( + receiver, + config_entry, + device_info, + "main_zone", + "main_zone", + FEATURES_MAIN, + ) + + @override + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the device.""" + if self._receiver.power_on: + return MediaPlayerState.ON + return MediaPlayerState.OFF + + @override + @property + def source_list(self) -> list[str] | None: + """Return a list of available input sources.""" + return self._receiver.available_sources + + @override + @property + def sound_mode_list(self) -> list[str] | None: + """Return a list of available sound modes.""" + return self._receiver.available_sound_modes + + @override + @property + def is_volume_muted(self) -> bool | None: + """Return boolean if volume is currently muted.""" + return self._receiver.mute_enabled + + @override + @property + def volume_level(self) -> float | None: + """Volume level of the media player (0..1).""" + if not isinstance(self._receiver.volume, float): + return None + return _to_ha_volume(self._receiver.volume) + + @override + @property + def source(self) -> str | None: + """Return the current input source.""" + return self._receiver.source + + @override + @property + def sound_mode(self) -> str | None: + """Return the current sound mode.""" + return self._receiver.sound_mode + + @override + def turn_on(self) -> None: + """Turn on media player.""" + self._receiver.power_on = True + + @override + def turn_off(self) -> None: + """Turn off media player.""" + self._receiver.power_on = False + + def volume_up(self) -> None: + """Volume up the media player.""" + self._receiver.volume_up() + + def volume_down(self) -> None: + """Volume down the media player.""" + self._receiver.volume_down() + + @override + def set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self._receiver.volume = _to_lyngdorf_volume(volume) + + @override + def mute_volume(self, mute: bool) -> None: + """Send mute command.""" + self._receiver.mute_enabled = mute + + @override + def select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + self._receiver.sound_mode = sound_mode + + @override + def select_source(self, source: str) -> None: + """Select input source.""" + self._receiver.source = source diff --git a/homeassistant/components/lyngdorf/models.py b/homeassistant/components/lyngdorf/models.py new file mode 100644 index 000000000000..26e2f84b027a --- /dev/null +++ b/homeassistant/components/lyngdorf/models.py @@ -0,0 +1,20 @@ +"""Models for Lyngdorf integration.""" + +from dataclasses import dataclass + +from lyngdorf.device import Receiver + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo + + +@dataclass +class LyngdorfRuntimeData: + """Runtime data for Lyngdorf integration.""" + + receiver: Receiver + device_info: DeviceInfo + zone_b_device_info: DeviceInfo + + +type LyngdorfConfigEntry = ConfigEntry[LyngdorfRuntimeData] diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml new file mode 100644 index 000000000000..a9a72c80f4d9 --- /dev/null +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: Integration is push-based; entities update via receiver callbacks. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not use authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: done + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Single device per config entry. + entity-category: + status: exempt + comment: Media player entities do not need entity categories. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities are useful by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Media player uses default platform icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues needed. + stale-devices: + status: exempt + comment: Single device per config entry. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: Integration uses local TCP, not HTTP. + strict-typing: todo diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json new file mode 100644 index 000000000000..c47d256d32b8 --- /dev/null +++ b/homeassistant/components/lyngdorf/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_determine_id": "[%key:component::lyngdorf::config::error::cannot_determine_id%]", + "unsupported_model": "This Lyngdorf model is not supported" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_determine_id": "Could not determine device identity. Ensure the device is powered on and reachable.", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unsupported_model": "This Lyngdorf model is not supported" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Do you want to set up **{name}**?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of the Lyngdorf device" + }, + "title": "Lyngdorf device connection" + } + } + }, + "device": { + "zone_b": { + "name": "{device_name} Zone B" + } + }, + "entity": { + "media_player": { + "main_zone": { + "name": "Main zone" + } + } + }, + "exceptions": { + "setup_connection_error": { + "message": "Failed to connect to {host}" + }, + "setup_timeout": { + "message": "Timeout connecting to {host}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 00f5983f6f73..103a914f62ea 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -443,6 +443,7 @@ FLOWS = { "lupusec", "lutron", "lutron_caseta", + "lyngdorf", "lyric", "madvr", "mailgun", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 95eedf8ef476..46b56b722cd7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4039,6 +4039,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "lyngdorf": { + "name": "Lyngdorf", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "madeco": { "name": "Madeco", "integration_type": "virtual", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index ecb94f5d1e1f..28d71f8281c4 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -206,6 +206,12 @@ SSDP = { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1", }, ], + "lyngdorf": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Lyngdorf", + }, + ], "nanoleaf": [ { "st": "Nanoleaf_aurora:light", diff --git a/requirements_all.txt b/requirements_all.txt index f9fe758b27a7..1a6f12100f86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1521,6 +1521,9 @@ lw12==0.9.2 # homeassistant.components.scrape lxml==6.1.1 +# homeassistant.components.lyngdorf +lyngdorf==1.3.3 + # homeassistant.components.matrix matrix-nio==0.25.2 diff --git a/tests/components/lyngdorf/__init__.py b/tests/components/lyngdorf/__init__.py new file mode 100644 index 000000000000..d9323f618132 --- /dev/null +++ b/tests/components/lyngdorf/__init__.py @@ -0,0 +1 @@ +"""Tests for the Lyngdorf integration.""" diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py new file mode 100644 index 000000000000..1cb548a3d220 --- /dev/null +++ b/tests/components/lyngdorf/conftest.py @@ -0,0 +1,114 @@ +"""Fixtures for the Lyngdorf integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from lyngdorf.const import LyngdorfModel +from lyngdorf.device import Receiver +import pytest + +from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def ssdp_scanner_mock() -> Generator[Mock]: + """Mock the SSDP Scanner.""" + with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: + reg_callback = mock_scanner.return_value.async_register_callback + reg_callback.return_value = Mock(return_value=None) + yield mock_scanner.return_value + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Mock Lyngdorf", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_MODEL: "MP-60", + CONF_SERIAL_NUMBER: "0050c27c76b2", + }, + unique_id="0050c27c76b2", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.lyngdorf.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_receiver() -> Generator[MagicMock]: + """Return a mocked Lyngdorf receiver.""" + with patch( + "homeassistant.components.lyngdorf.async_create_receiver" + ) as create_mock: + receiver = MagicMock(spec=Receiver) + receiver.name = "Mock Lyngdorf" + receiver.connected = True + + receiver.power_on = False + receiver.volume = -40.0 + receiver.mute_enabled = False + receiver.source = None + receiver.available_sources = [] + receiver.sound_mode = None + receiver.available_sound_modes = [] + + receiver.zone_b_power_on = False + receiver.zone_b_volume = -40.0 + receiver.zone_b_mute_enabled = False + receiver.zone_b_source = None + receiver.zone_b_available_sources = [] + + create_mock.return_value = receiver + yield receiver + + +@pytest.fixture +def mock_get_device_serial() -> Generator[AsyncMock]: + """Return a mocked async_get_device_serial function.""" + with patch( + "homeassistant.components.lyngdorf.config_flow.async_get_device_serial", + new=AsyncMock(return_value="0050c27c76b2"), + ) as serial_mock: + yield serial_mock + + +@pytest.fixture +def mock_find_receiver_model() -> Generator[AsyncMock]: + """Return a mocked async_find_receiver_model function.""" + with patch( + "homeassistant.components.lyngdorf.config_flow.async_find_receiver_model", + new=AsyncMock(return_value=LyngdorfModel.MP_60), + ) as find_mock: + yield find_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> MockConfigEntry: + """Set up the Lyngdorf integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.lyngdorf.lookup_receiver_model") as lookup: + lookup.return_value = LyngdorfModel.MP_60 + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/lyngdorf/snapshots/test_media_player.ambr b/tests/components/lyngdorf/snapshots/test_media_player.ambr new file mode 100644 index 000000000000..fd9a7f661b94 --- /dev/null +++ b/tests/components/lyngdorf/snapshots/test_media_player.ambr @@ -0,0 +1,107 @@ +# serializer version: 1 +# name: test_entities[media_player.mock_lyngdorf_main_zone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Main zone', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main zone', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'main_zone', + 'unique_id': '0050c27c76b2_main_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.mock_lyngdorf_main_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : 'Mock Lyngdorf Main zone', + : , + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[media_player.mock_lyngdorf_zone_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0050c27c76b2_zone_b', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.mock_lyngdorf_zone_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : 'Mock Lyngdorf Zone B', + : , + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py new file mode 100644 index 000000000000..e60a79970344 --- /dev/null +++ b/tests/components/lyngdorf/test_config_flow.py @@ -0,0 +1,367 @@ +"""Configuration flow tests for the Lyngdorf integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from lyngdorf.const import LyngdorfModel +import pytest + +from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_SERIAL = "0050c27c76b2" + + +@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user configuration flow with serial lookup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.unique_id == MOCK_SERIAL + assert config_entry.data[CONF_HOST] == "192.168.1.100" + assert config_entry.data[CONF_SERIAL_NUMBER] == MOCK_SERIAL + assert config_entry.title == "mp-60" + + +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_user_flow_cannot_determine_id_recovers( + hass: HomeAssistant, + mock_get_device_serial: AsyncMock, +) -> None: + """Test user flow shows error when serial cannot be determined, then recovers.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_get_device_serial.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_determine_id"} + + mock_get_device_serial.return_value = MOCK_SERIAL + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_SERIAL + + +@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user flow when device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_get_device_serial") +async def test_user_flow_unsupported_model_recovers( + hass: HomeAssistant, + mock_find_receiver_model: AsyncMock, +) -> None: + """Test user flow shows unsupported_model error, then recovers.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_find_receiver_model.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_model"} + + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exc", "expected_error"), + [ + (ConnectionError("Unable to connect"), "cannot_connect"), + (TimeoutError("Connection timeout"), "timeout_connect"), + (Exception("Unexpected error"), "unknown"), + ], + ids=["cannot_connect", "timeout", "unknown"], +) +@pytest.mark.usefixtures("mock_get_device_serial") +async def test_user_flow_connection_errors_recover( + hass: HomeAssistant, + mock_find_receiver_model: AsyncMock, + exc: Exception, + expected_error: str, +) -> None: + """Test user flow surfaces connection errors and then recovers.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_find_receiver_model.side_effect = exc + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_find_receiver_model.side_effect = None + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("friendly_name", "expected_title"), + [ + pytest.param("Living Room", "Living Room", id="custom_name"), + pytest.param("mp-60", "mp-60", id="name_matches_model"), + ], +) +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_ssdp_discovery( + hass: HomeAssistant, friendly_name: str, expected_title: str +) -> None: + """Test successful SSDP discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: friendly_name, + ATTR_UPNP_MODEL_NAME: "MP-60", + ATTR_UPNP_SERIAL: MOCK_SERIAL, + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.unique_id == MOCK_SERIAL + assert config_entry.title == expected_title + assert config_entry.data[CONF_HOST] == "192.168.1.100" + assert config_entry.data[CONF_SERIAL_NUMBER] == MOCK_SERIAL + + +async def test_ssdp_discovery_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SSDP discovery aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + ATTR_UPNP_SERIAL: MOCK_SERIAL, + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.100" + + +async def test_ssdp_discovery_no_serial(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when no serial number is available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_determine_id" + + +async def test_ssdp_discovery_unsupported_model(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when model is not supported.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "UNKNOWN-MODEL", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_model" + + +async def test_ssdp_discovery_missing_model(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when model name is missing.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_model" + + +@pytest.mark.parametrize( + "ssdp_location", + [ + pytest.param(None, id="no_location"), + pytest.param("http:///desc.xml", id="no_hostname"), + ], +) +async def test_ssdp_discovery_no_host( + hass: HomeAssistant, ssdp_location: str | None +) -> None: + """Test SSDP discovery aborts when no hostname can be determined.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=ssdp_location, + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("side_effect", "expected_reason"), + [ + pytest.param(OSError("Connection refused"), "cannot_connect", id="os_error"), + pytest.param( + TimeoutError("Connection timeout"), "cannot_connect", id="timeout" + ), + pytest.param(None, "unsupported_model", id="model_not_found"), + ], +) +async def test_ssdp_discovery_connectivity_check_aborts( + hass: HomeAssistant, + mock_find_receiver_model: AsyncMock, + side_effect: Exception | None, + expected_reason: str, +) -> None: + """Test SSDP discovery aborts when the connectivity check fails.""" + mock_find_receiver_model.side_effect = side_effect + mock_find_receiver_model.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + ATTR_UPNP_SERIAL: MOCK_SERIAL, + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason diff --git a/tests/components/lyngdorf/test_init.py b/tests/components/lyngdorf/test_init.py new file mode 100644 index 000000000000..c1fe914b75f2 --- /dev/null +++ b/tests/components/lyngdorf/test_init.py @@ -0,0 +1,112 @@ +"""Tests for the Lyngdorf integration.""" + +from unittest.mock import MagicMock, patch + +from lyngdorf.const import LyngdorfModel +import pytest + +from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "exc", + [ + ConnectionError("Connection failed"), + OSError("Network unreachable"), + TimeoutError("Connection timeout"), + ], + ids=["connection_error", "os_error", "timeout"], +) +async def test_setup_entry_connection_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + exc: Exception, +) -> None: + """Test setup retries when connecting to the receiver fails.""" + mock_config_entry.add_to_hass(hass) + mock_receiver.async_connect.side_effect = exc + + with patch( + "homeassistant.components.lyngdorf.lookup_receiver_model", + return_value=LyngdorfModel.MP_60, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_receiver_disconnects_on_hass_stop( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test the receiver is disconnected when Home Assistant stops.""" + assert init_integration.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_receiver.async_disconnect.assert_awaited_once() + + +async def test_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test unloading the config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("serial", "expected_mac_connections"), + [ + ("0050c27c76b2", {"00:50:c2:7c:76:b2"}), + ("NOT-A-MAC", set()), + ], + ids=["valid_mac", "non_mac_serial"], +) +@pytest.mark.usefixtures("mock_receiver") +async def test_mac_connection_registered_when_serial_is_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + serial: str, + expected_mac_connections: set[str], +) -> None: + """Test that the device gets a MAC connection only when serial parses as one.""" + entry = MockConfigEntry( + title="Mock Lyngdorf", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_MODEL: "MP-60", + CONF_SERIAL_NUMBER: serial, + }, + unique_id=serial.lower(), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.lyngdorf.lookup_receiver_model", + return_value=LyngdorfModel.MP_60, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, serial.lower())}) + assert device is not None + mac_connections = { + value for kind, value in device.connections if kind == dr.CONNECTION_NETWORK_MAC + } + assert mac_connections == expected_mac_connections diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py new file mode 100644 index 000000000000..8d9bcd84092b --- /dev/null +++ b/tests/components/lyngdorf/test_media_player.py @@ -0,0 +1,301 @@ +"""Tests for the Lyngdorf media player platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +MAIN_ZONE = "media_player.mock_lyngdorf_main_zone" +ZONE_B = "media_player.mock_lyngdorf_zone_b" + + +async def test_entities( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the media player entities.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "service", "attr", "expected"), + [ + (MAIN_ZONE, SERVICE_TURN_ON, "power_on", True), + (MAIN_ZONE, SERVICE_TURN_OFF, "power_on", False), + (ZONE_B, SERVICE_TURN_ON, "zone_b_power_on", True), + (ZONE_B, SERVICE_TURN_OFF, "zone_b_power_on", False), + ], +) +async def test_power( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + service: str, + attr: str, + expected: bool, +) -> None: + """Test turning power on/off for both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert getattr(mock_receiver, attr) is expected + + +@pytest.mark.parametrize( + ("entity_id", "service", "method"), + [ + (MAIN_ZONE, SERVICE_VOLUME_UP, "volume_up"), + (MAIN_ZONE, SERVICE_VOLUME_DOWN, "volume_down"), + (ZONE_B, SERVICE_VOLUME_UP, "zone_b_volume_up"), + (ZONE_B, SERVICE_VOLUME_DOWN, "zone_b_volume_down"), + ], +) +async def test_volume_step( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + service: str, + method: str, +) -> None: + """Test volume up/down for both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(mock_receiver, method).assert_called_once() + + +@pytest.mark.parametrize( + ("entity_id", "level", "attr", "expected_db"), + [ + (MAIN_ZONE, 0.5, "volume", -31.0), + (MAIN_ZONE, 1.0, "volume", 18.0), + (ZONE_B, 0.3, "zone_b_volume", -50.6), + ], +) +async def test_volume_set( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + level: float, + attr: str, + expected_db: float, +) -> None: + """Test setting and clamping volume on both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: level}, + blocking=True, + ) + assert getattr(mock_receiver, attr) == pytest.approx(expected_db) + + +@pytest.mark.parametrize( + ("entity_id", "attr"), + [ + (MAIN_ZONE, "mute_enabled"), + (ZONE_B, "zone_b_mute_enabled"), + ], +) +async def test_mute( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + attr: str, +) -> None: + """Test muting both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + assert getattr(mock_receiver, attr) is True + + +@pytest.mark.parametrize( + ("entity_id", "attr"), + [ + (MAIN_ZONE, "source"), + (ZONE_B, "zone_b_source"), + ], +) +async def test_select_source( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + attr: str, +) -> None: + """Test selecting source on both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: "HDMI"}, + blocking=True, + ) + assert getattr(mock_receiver, attr) == "HDMI" + + +async def test_select_sound_mode( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test selecting sound mode on the main zone.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: MAIN_ZONE, ATTR_SOUND_MODE: "Movie"}, + blocking=True, + ) + assert mock_receiver.sound_mode == "Movie" + + +async def test_availability( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test availability when device disconnects and reconnects.""" + callbacks = [ + call.args[0] + for call in mock_receiver.register_notification_callback.call_args_list + ] + assert callbacks + + mock_receiver.connected = False + for cb in callbacks: + cb() + await hass.async_block_till_done() + + assert hass.states.get(MAIN_ZONE).state == STATE_UNAVAILABLE + assert hass.states.get(ZONE_B).state == STATE_UNAVAILABLE + + mock_receiver.connected = True + for cb in callbacks: + cb() + await hass.async_block_till_done() + + assert hass.states.get(MAIN_ZONE).state != STATE_UNAVAILABLE + assert hass.states.get(ZONE_B).state != STATE_UNAVAILABLE + + +async def test_main_zone_state_properties( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test main zone state properties are reported correctly.""" + callbacks = [ + call.args[0] + for call in mock_receiver.register_notification_callback.call_args_list + ] + + mock_receiver.power_on = True + mock_receiver.volume = -40.0 + mock_receiver.mute_enabled = False + mock_receiver.source = "HDMI" + mock_receiver.sound_mode = "Movie" + mock_receiver.available_sources = ["HDMI", "Optical"] + mock_receiver.available_sound_modes = ["Movie", "Stereo"] + for cb in callbacks: + cb() + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert state.state == MediaPlayerState.ON + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.408, abs=0.01) + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False + assert state.attributes[ATTR_INPUT_SOURCE] == "HDMI" + assert state.attributes[ATTR_SOUND_MODE] == "Movie" + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] + assert state.attributes[ATTR_SOUND_MODE_LIST] == ["Movie", "Stereo"] + + mock_receiver.volume = None + for cb in callbacks: + cb() + await hass.async_block_till_done() + state = hass.states.get(MAIN_ZONE) + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None + + mock_receiver.power_on = False + for cb in callbacks: + cb() + await hass.async_block_till_done() + state = hass.states.get(MAIN_ZONE) + assert state.state == MediaPlayerState.OFF + + +async def test_zone_b_state_properties( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test zone B state properties are reported correctly.""" + callbacks = [ + call.args[0] + for call in mock_receiver.register_notification_callback.call_args_list + ] + + mock_receiver.zone_b_power_on = True + mock_receiver.zone_b_volume = -30.0 + mock_receiver.zone_b_mute_enabled = True + mock_receiver.zone_b_source = "Optical" + mock_receiver.zone_b_available_sources = ["HDMI", "Optical"] + for cb in callbacks: + cb() + await hass.async_block_till_done() + + state = hass.states.get(ZONE_B) + assert state.state == MediaPlayerState.ON + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.510, abs=0.01) + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True + assert state.attributes[ATTR_INPUT_SOURCE] == "Optical" + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] + + mock_receiver.zone_b_volume = "invalid" + for cb in callbacks: + cb() + await hass.async_block_till_done() + state = hass.states.get(ZONE_B) + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None From e0a6f7cd8cf1641dcf1ccaba519f54149576bd9e Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 10 Jul 2026 13:54:57 -0600 Subject: [PATCH 468/707] Fix weatherflow_cloud websocket double-connect (#169573) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/weatherflow_cloud/__init__.py | 36 +++++--- .../weatherflow_cloud/coordinator.py | 4 +- .../components/weatherflow_cloud/conftest.py | 2 + .../weatherflow_cloud/test_coordinators.py | 16 +--- .../components/weatherflow_cloud/test_init.py | 83 +++++++++++++++++++ 5 files changed, 113 insertions(+), 28 deletions(-) create mode 100644 tests/components/weatherflow_cloud/test_init.py diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index d9860bdb0fe5..5a02f76d163d 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -4,10 +4,13 @@ import asyncio from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.ws import WeatherFlowWebsocketAPI +from websockets.exceptions import WebSocketException from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import client_context from .const import LOGGER from .coordinator import ( @@ -67,11 +70,26 @@ async def async_setup_entry( stations=stations, ) - # Run setup method - await asyncio.gather( - websocket_wind_coordinator.async_setup(), - websocket_observation_coordinator.async_setup(), - ) + async def _async_disconnect_websocket() -> None: + """Disconnect the WeatherFlow websocket.""" + await websocket_api.stop_all_listeners() + await websocket_api.close() + + # Connect once because both websocket coordinators share this API instance. + try: + await websocket_api.connect(client_context()) + except (OSError, WebSocketException) as err: + raise ConfigEntryNotReady("Error connecting to WeatherFlow websocket") from err + + entry.async_on_unload(_async_disconnect_websocket) + + try: + await asyncio.gather( + websocket_wind_coordinator.async_setup(), + websocket_observation_coordinator.async_setup(), + ) + except (OSError, WebSocketException) as err: + raise ConfigEntryNotReady("Error setting up WeatherFlow websocket") from err entry.runtime_data = WeatherFlowCoordinators( rest_data_coordinator, @@ -80,14 +98,6 @@ async def async_setup_entry( ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Websocket disconnect handler - async def _async_disconnect_websocket() -> None: - await websocket_api.stop_all_listeners() - await websocket_api.close() - - # Register a websocket shutdown handler - entry.async_on_unload(_async_disconnect_websocket) - return True diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 609c00899e98..448d7096aa31 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -26,7 +26,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.ssl import client_context from .const import DOMAIN, LOGGER @@ -148,8 +147,7 @@ class BaseWebsocketCoordinator[T](BaseWeatherFlowCoordinator[dict[int, T | None] } async def async_setup(self) -> None: - """Set up the websocket connection.""" - await self.websocket_api.connect(client_context()) + """Register callbacks and subscribe to device messages.""" self.websocket_api.register_callback( message_type=self._event_type, callback=self._handle_websocket_message, diff --git a/tests/components/weatherflow_cloud/conftest.py b/tests/components/weatherflow_cloud/conftest.py index 0a2a0bff005e..7c41b305da26 100644 --- a/tests/components/weatherflow_cloud/conftest.py +++ b/tests/components/weatherflow_cloud/conftest.py @@ -151,6 +151,8 @@ async def mock_websocket_api(): mock_ws_instance.connect = AsyncMock() mock_ws_instance.send_message = AsyncMock() mock_ws_instance.register_callback = MagicMock() + mock_ws_instance.stop_all_listeners = AsyncMock() + mock_ws_instance.close = AsyncMock() mock_ws_instance.websocket = mock_websocket with ( diff --git a/tests/components/weatherflow_cloud/test_coordinators.py b/tests/components/weatherflow_cloud/test_coordinators.py index bb38cfacac88..4dd143cfc323 100644 --- a/tests/components/weatherflow_cloud/test_coordinators.py +++ b/tests/components/weatherflow_cloud/test_coordinators.py @@ -34,7 +34,7 @@ async def test_wind_coordinator_setup( mock_websocket_api: AsyncMock, mock_stations_data: Mock, ) -> None: - """Test wind coordinator setup.""" + """Test wind coordinator setup registers callbacks without connecting.""" coordinator = WeatherFlowWindCoordinator( hass=hass, @@ -46,16 +46,12 @@ async def test_wind_coordinator_setup( await coordinator.async_setup() - # Verify websocket setup - mock_websocket_api.connect.assert_called_once() + mock_websocket_api.connect.assert_not_called() mock_websocket_api.register_callback.assert_called_once_with( message_type=EventType.RAPID_WIND, callback=coordinator._handle_websocket_message, ) - # In the refactored code, send_message is called for each device ID assert mock_websocket_api.send_message.called - - # Verify at least one message is of the correct type call_args_list = mock_websocket_api.send_message.call_args_list assert any( isinstance(call.args[0], RapidWindListenStartMessage) for call in call_args_list @@ -69,7 +65,7 @@ async def test_observation_coordinator_setup( mock_websocket_api: AsyncMock, mock_stations_data: Mock, ) -> None: - """Test observation coordinator setup.""" + """Test observation coordinator setup registers callbacks without connecting.""" coordinator = WeatherFlowObservationCoordinator( hass=hass, @@ -81,16 +77,12 @@ async def test_observation_coordinator_setup( await coordinator.async_setup() - # Verify websocket setup - mock_websocket_api.connect.assert_called_once() + mock_websocket_api.connect.assert_not_called() mock_websocket_api.register_callback.assert_called_once_with( message_type=EventType.OBSERVATION, callback=coordinator._handle_websocket_message, ) - # In the refactored code, send_message is called for each device ID assert mock_websocket_api.send_message.called - - # Verify at least one message is of the correct type call_args_list = mock_websocket_api.send_message.call_args_list assert any(isinstance(call.args[0], ListenStartMessage) for call in call_args_list) diff --git a/tests/components/weatherflow_cloud/test_init.py b/tests/components/weatherflow_cloud/test_init.py new file mode 100644 index 000000000000..5030f1481980 --- /dev/null +++ b/tests/components/weatherflow_cloud/test_init.py @@ -0,0 +1,83 @@ +"""Tests for weatherflow_cloud __init__ setup.""" + +from unittest.mock import AsyncMock + +import pytest +from websockets.exceptions import ConnectionClosedError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.util.ssl import client_context + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_websocket_connect_called_once( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the shared websocket is connected exactly once during setup.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_websocket_api.connect.assert_awaited_once_with(client_context()) + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_entry_unload( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unloading an entry closes the websocket.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_websocket_api.stop_all_listeners.assert_awaited_once() + mock_websocket_api.close.assert_awaited_once() + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_setup_failure_cleans_up_websocket( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test partial setup failure stops listeners and closes the websocket.""" + mock_config_entry.add_to_hass(hass) + mock_websocket_api.send_message.side_effect = ConnectionClosedError(None, None) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_websocket_api.stop_all_listeners.assert_awaited_once() + mock_websocket_api.close.assert_awaited_once() + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_websocket_connect_failure_sets_entry_not_ready( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test websocket connection failure triggers setup retry.""" + mock_config_entry.add_to_hass(hass) + mock_websocket_api.connect.side_effect = OSError("connect failed") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_websocket_api.connect.assert_awaited_once_with(client_context()) + mock_websocket_api.stop_all_listeners.assert_not_awaited() + mock_websocket_api.close.assert_not_awaited() From b34412d10f4dcefafaa693f2957e64bda6237dba Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 11 Jul 2026 06:03:51 +1000 Subject: [PATCH 469/707] Fix Teslemetry dead-token retry storm (#175671) --- .../components/teslemetry/__init__.py | 37 ++++-- tests/components/teslemetry/test_init.py | 123 +++++++++++++++++- 2 files changed, 150 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 4f8a8a06c295..f9815ff9f46d 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -5,7 +5,7 @@ from collections.abc import Callable from functools import partial from typing import Any, Final, cast -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, @@ -21,10 +21,15 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -90,18 +95,32 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str: oauth_session.valid_token, oauth_session.token.get("expires_at"), ) + setup_in_progress = ( + oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS + ) try: await oauth_session.async_ensure_token_valid() - except ClientResponseError as err: - if err.status == 401: + except OAuth2TokenRequestReauthError as err: + if setup_in_progress: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_failed", ) from err - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="not_ready_connection_error", - ) from err + # Not in setup: let the coordinator's own OAuth2TokenRequestError + # handling stop polling and (re)start reauth without tearing + # down the already-loaded entry. + oauth_session.config_entry.async_start_reauth(oauth_session.hass) + raise + except OAuth2TokenRequestError as err: + # Recoverable (e.g. 429/5xx). During setup this backs off via the + # normal ConfigEntryNotReady retry; once loaded, let it propagate so + # the coordinator treats it as a transient failed update instead. + if setup_in_progress: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="not_ready_connection_error", + ) from err + raise except (KeyError, TypeError) as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 3598dc5da597..2e37d0c1d9c6 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -19,6 +19,7 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) +from homeassistant.components.teslemetry import _get_access_token from homeassistant.components.teslemetry.const import CLIENT_ID, DOMAIN # Coordinator constants @@ -31,6 +32,7 @@ from homeassistant.components.teslemetry.coordinator import ( VEHICLE_INTERVAL, ) from homeassistant.components.teslemetry.models import TeslemetryData +from homeassistant.components.teslemetry.oauth import TeslemetryImplementation from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -40,10 +42,17 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import UpdateFailed -from . import setup_platform +from . import mock_config_entry, setup_platform from .const import ( CONFIG_V1, ENERGY_HISTORY, @@ -1040,3 +1049,115 @@ async def test_insufficient_credits_backs_off_polling( coordinator = entry.runtime_data.vehicles[0].coordinator assert isinstance(coordinator.last_exception, UpdateFailed) assert coordinator.last_exception.retry_after == INSUFFICIENT_CREDITS_RETRY_AFTER + + +def _oauth_session(hass: HomeAssistant, entry: MockConfigEntry) -> OAuth2Session: + """Build an OAuth2Session for directly exercising _get_access_token.""" + return OAuth2Session(hass, entry, TeslemetryImplementation(hass, DOMAIN, CLIENT_ID)) + + +async def test_get_access_token_dead_token_during_setup_triggers_auth_failed( + hass: HomeAssistant, +) -> None: + """A dead/revoked refresh token during setup must raise ConfigEntryAuthFailed. + + OAuth servers commonly report a dead refresh token with a non-401 status + (e.g. 400 invalid_grant). Only recognizing status 401 let this fall + through to ConfigEntryNotReady, which retries setup indefinitely without + ever prompting the user to reauthenticate. + """ + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=MagicMock(), status=400, domain=DOMAIN + ), + ), + pytest.raises(ConfigEntryAuthFailed), + ): + await _get_access_token(session) + + +async def test_get_access_token_rate_limited_during_setup_is_not_fatal( + hass: HomeAssistant, +) -> None: + """A 429 from the token endpoint during setup should back off, not be fatal.""" + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestTransientError( + request_info=MagicMock(), status=429, domain=DOMAIN + ), + ), + pytest.raises(ConfigEntryNotReady), + ): + await _get_access_token(session) + + +async def test_get_access_token_dead_token_after_setup_starts_reauth( + hass: HomeAssistant, +) -> None: + """Test a token dying after setup (re)starts reauth without tearing down. + + The coordinator handles the rest once the exception is re-raised. + """ + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.LOADED) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=MagicMock(), status=400, domain=DOMAIN + ), + ), + pytest.raises(OAuth2TokenRequestReauthError), + ): + await _get_access_token(session) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert any( + flow["handler"] == DOMAIN and flow["context"].get("source") == "reauth" + for flow in flows + ) + + +async def test_get_access_token_rate_limited_after_setup_is_not_fatal( + hass: HomeAssistant, +) -> None: + """A transient token-refresh error after setup must not force reauth.""" + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.LOADED) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestTransientError( + request_info=MagicMock(), status=429, domain=DOMAIN + ), + ), + pytest.raises(OAuth2TokenRequestTransientError), + ): + await _get_access_token(session) + await hass.async_block_till_done() + + assert not hass.config_entries.flow.async_progress() From eb09af591e01ee38d4e76f825a769d02812e3880 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:04:14 +0200 Subject: [PATCH 470/707] Remove UniFi Protect AI Port support (#174378) --- .../components/unifiprotect/camera.py | 4 -- .../components/unifiprotect/const.py | 1 - .../components/unifiprotect/entity.py | 5 ++- .../components/unifiprotect/migrate.py | 39 ++++++++++++++++++- tests/components/unifiprotect/test_camera.py | 36 ----------------- tests/components/unifiprotect/test_migrate.py | 37 +++++++++++++++++- tests/components/unifiprotect/test_sensor.py | 32 +++++++++------ 7 files changed, 99 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 32f6238ee21f..b923572931f3 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -149,10 +149,6 @@ async def async_setup_entry( async_dispatcher_connect(hass, data.channels_signal, _add_new_device) ) - # Clean up any erroneously created RTSP issues for AI Ports - for device in data.get_by_types({ModelType.AIPORT}): - ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{device.id}") - async_add_entities(_async_camera_entities(hass, entry, data)) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index beaf24d29eda..717f960ca2f9 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -41,7 +41,6 @@ DEFAULT_VERIFY_SSL = False DEFAULT_MAX_MEDIA = 1000 DEVICES_THAT_ADOPT = { - ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.VIEWPORT, diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 4c8c61265d1b..06b07cf51bf1 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -164,7 +164,6 @@ def _async_device_entities( _ALL_MODEL_TYPES = ( - ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.SENSOR, @@ -208,6 +207,10 @@ def async_all_device_entities( device_model_type = ufp_device.model assert device_model_type is not None + # Runtime adoption must honor the same model-type allowlist as initial setup, + # so unsupported devices (e.g. AI Port) get no entities when adopted live. + if device_model_type not in _ALL_MODEL_TYPES: + return [] descs = _combine_model_descs(device_model_type, model_descriptions, all_descs) return _async_device_entities( data, klass, device_model_type, descs, unadopted_descs, ufp_device diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 3e1804f6d19d..8ed230acdf89 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -11,7 +11,11 @@ from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.issue_registry import IssueSeverity from .const import DOMAIN @@ -109,6 +113,10 @@ async def async_migrate_data( async_deprecate_hdr(hass, entry) _LOGGER.debug("Completed Migrate: async_deprecate_hdr") + _LOGGER.debug("Start Migrate: async_remove_aiport_devices") + async_remove_aiport_devices(hass, entry) + _LOGGER.debug("Completed Migrate: async_remove_aiport_devices") + _LOGGER.debug("Start Migrate: async_migrate_insecure_cameras") async_migrate_insecure_cameras(hass, entry) _LOGGER.debug("Completed Migrate: async_migrate_insecure_cameras") @@ -118,6 +126,35 @@ async def async_migrate_data( _LOGGER.debug("Completed Migrate: async_remove_package_binary_sensor") +# Device type (``ProtectAdoptableDeviceModel.type``) reported by AI Ports. Matched +# in the registry so cleanup does not depend on the bundled library still exposing +# the AI Port model. +_AIPORT_DEVICE_TYPE = "AI Port" + + +@callback +def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> None: + """Remove AI Port devices and their diagnostic-only entities. + + AI Ports only ever exposed diagnostic sensors (no automation-relevant + functionality) and behave transparently, extending the camera they back. + They have no public API representation, so support is dropped. Devices are + matched from the registry (by device type) rather than the live bootstrap, so + cleanup works even once the library drops the AI Port model. + + Added in 2026.7.0 + """ + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if device.model_id != _AIPORT_DEVICE_TYPE: + continue + # Detaching the config entry removes the device (it has no other entry) + # and its entities along with it. + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + @callback def async_migrate_insecure_cameras(hass: HomeAssistant, entry: UFPConfigEntry) -> None: """Migrate the legacy plain-RTSP "(insecure)" camera entities. diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 50684ad406fe..8c429e5397ae 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -383,42 +383,6 @@ async def test_aiport_no_camera_entities( assert_entity_counts(hass, Platform.CAMERA, 0, 0) -async def test_aiport_stream_issue_cleanup( - hass: HomeAssistant, - ufp: MockUFPFixture, - aiport: AiPort, - issue_registry: ir.IssueRegistry, -) -> None: - """Stale public-stream issues for AI Ports are cleaned up on setup.""" - await init_entry(hass, ufp, [aiport]) - - issue_id = f"rtsp_disabled_{aiport.id}" - # Simulate a legacy issue created directly (bypass translation validation). - issue_registry.issues[(DOMAIN, issue_id)] = ir.IssueEntry( - active=True, - breaks_in_ha_version=None, - created=None, - data=None, - dismissed_version=None, - domain=DOMAIN, - is_fixable=True, - is_persistent=False, - issue_domain=None, - issue_id=issue_id, - learn_more_url=None, - severity=ir.IssueSeverity.WARNING, - translation_key="rtsp_disabled", - translation_placeholders=None, - ) - assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None - - await hass.config_entries.async_reload(ufp.entry.entry_id) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue(DOMAIN, issue_id) is None - assert_entity_counts(hass, Platform.CAMERA, 0, 0) - - async def test_snapshot_low_quality_without_stream( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 985b50c5f6f9..08dc5716263f 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -9,7 +9,11 @@ from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from .utils import MockUFPFixture, init_entry @@ -220,6 +224,37 @@ async def test_deprecate_entity_script( assert issue is None +async def test_migrate_remove_aiport_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, +) -> None: + """A leftover AI Port device/entity is removed by type, bootstrap-independent.""" + mac = "AABBCCDDEEFF" + device = device_registry.async_get_or_create( + config_entry_id=ufp.entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + model_id="AI Port", + ) + entity = entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + f"{mac}_uptime", + config_entry=ufp.entry, + device_id=device.id, + ) + + # AI Port deliberately absent from the bootstrap — cleanup is registry-based + await init_entry(hass, ufp, []) + + assert entity_registry.async_get(entity.entity_id) is None + assert ( + device_registry.async_get_device(connections={(dr.CONNECTION_NETWORK_MAC, mac)}) + is None + ) + + async def test_migrate_insecure_camera_redirected( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 25531ca43c68..499e66ff68c7 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -692,22 +692,32 @@ async def test_sensor_precision( assert hass.states.get(entity_id).state == "17.49" -async def test_aiport_no_camera_sensor_entities( +async def test_aiport_no_sensor_entities( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, aiport: AiPort, ) -> None: - """Test that AI Port devices do not create camera-specific sensor entities.""" + """AI Port devices create no entities (support dropped).""" await init_entry(hass, ufp, [aiport]) - # AI Port should only create base device sensors, not camera-specific sensors - # The exact count may vary, but camera motion/detection sensors should not exist - entity_registry = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) + assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] - # Check no camera-specific sensors like motion detection exist - for entity in entities: - if entity.domain == Platform.SENSOR: - # Camera-specific sensors should not exist for AI Port - assert "detected_object" not in entity.unique_id - assert "last_motion" not in entity.unique_id + +async def test_aiport_no_sensor_entities_on_runtime_adopt( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, + aiport: AiPort, +) -> None: + """An AI Port adopted while running still creates no entities.""" + await init_entry(hass, ufp, [sensor_all]) + + aiport._api = ufp.api + aiport.feature_flags = Mock(is_ptz=False) + await adopt_devices(hass, ufp, [aiport]) + + entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) + assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] From 6a48e72c75556d31c685c47cdcc7e2ee45472299 Mon Sep 17 00:00:00 2001 From: some-random-climber <293766853+some-random-climber@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:05:18 +0200 Subject: [PATCH 471/707] Move service registration to async_setup in SimpliSafe (#175501) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- .../components/simplisafe/__init__.py | 224 +---------------- .../components/simplisafe/services.py | 235 ++++++++++++++++++ .../components/simplisafe/strings.json | 17 ++ 3 files changed, 263 insertions(+), 213 deletions(-) create mode 100644 homeassistant/components/simplisafe/services.py diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d65d930c7739..df648d618854 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -1,7 +1,6 @@ """Support for SimpliSafe alarm systems.""" import asyncio -from collections.abc import Callable, Coroutine from typing import Any from simplipy import API @@ -13,18 +12,6 @@ from simplipy.errors import ( WebsocketError, ) from simplipy.system import SystemNotification -from simplipy.system.v3 import ( - MAX_ALARM_DURATION, - MAX_ENTRY_DELAY_AWAY, - MAX_ENTRY_DELAY_HOME, - MAX_EXIT_DELAY_AWAY, - MAX_EXIT_DELAY_HOME, - MIN_ALARM_DURATION, - MIN_ENTRY_DELAY_AWAY, - MIN_EXIT_DELAY_AWAY, - SystemV3, - Volume, -) from simplipy.websocket import ( EVENT_AUTOMATIC_TEST, EVENT_CAMERA_MOTION_DETECTED, @@ -35,55 +22,38 @@ from simplipy.websocket import ( EVENT_USER_INITIATED_TEST, WebsocketEvent, ) -import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, - ATTR_DEVICE_ID, CONF_CODE, CONF_TOKEN, CONF_USERNAME, Platform, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry as dr, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.service import ( - async_register_admin_service, - verify_domain_control, -) +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( - ATTR_ALARM_DURATION, - ATTR_ALARM_VOLUME, - ATTR_CHIME_VOLUME, - ATTR_ENTRY_DELAY_AWAY, - ATTR_ENTRY_DELAY_HOME, - ATTR_EXIT_DELAY_AWAY, - ATTR_EXIT_DELAY_HOME, ATTR_LAST_EVENT_INFO, ATTR_LAST_EVENT_SENSOR_NAME, ATTR_LAST_EVENT_SENSOR_TYPE, ATTR_LAST_EVENT_TIMESTAMP, - ATTR_LIGHT, ATTR_SYSTEM_ID, - ATTR_VOICE_PROMPT_VOLUME, DISPATCHER_TOPIC_WEBSOCKET_EVENT, DOMAIN, LOGGER, ) from .coordinator import SimpliSafeDataUpdateCoordinator +from .services import async_setup_services from .typing import SystemType type SimpliSafeConfigEntry = ConfigEntry[SimpliSafe] @@ -94,9 +64,6 @@ ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial" ATTR_LAST_EVENT_TYPE = "last_event_type" ATTR_LAST_EVENT_TYPE = "last_event_type" ATTR_MESSAGE = "message" -ATTR_PIN_LABEL = "label" -ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" -ATTR_PIN_VALUE = "pin" ATTR_TIMESTAMP = "timestamp" WEBSOCKET_RECONNECT_RETRIES = 3 @@ -114,74 +81,6 @@ PLATFORMS = [ Platform.SENSOR, ] -VOLUME_MAP = { - "high": Volume.HIGH, - "low": Volume.LOW, - "medium": Volume.MEDIUM, - "off": Volume.OFF, -} - -SERVICE_NAME_REMOVE_PIN = "remove_pin" -SERVICE_NAME_SET_PIN = "set_pin" -SERVICE_NAME_SET_SYSTEM_PROPERTIES = "set_system_properties" - -SERVICES = ( - SERVICE_NAME_REMOVE_PIN, - SERVICE_NAME_SET_PIN, - SERVICE_NAME_SET_SYSTEM_PROPERTIES, -) - -SERVICE_REMOVE_PIN_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string, - } -) - -SERVICE_SET_PIN_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Required(ATTR_PIN_LABEL): cv.string, - vol.Required(ATTR_PIN_VALUE): cv.string, - }, -) - -SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional(ATTR_ALARM_DURATION): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION), - ), - vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), - vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), - vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY), - ), - vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(max=MAX_ENTRY_DELAY_HOME), - ), - vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY), - ), - vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(max=MAX_EXIT_DELAY_HOME), - ), - vol.Optional(ATTR_LIGHT): cv.boolean, - vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All( - vol.In(VOLUME_MAP), VOLUME_MAP.get - ), - } -) WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [ EVENT_AUTOMATIC_TEST, @@ -193,47 +92,13 @@ WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [ EVENT_USER_INITIATED_TEST, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -@callback -def _async_get_system_for_service_call( - hass: HomeAssistant, call: ServiceCall -) -> SystemType: - """Get the SimpliSafe system related to a service call (by device ID).""" - device_id = call.data[ATTR_DEVICE_ID] - device_registry = dr.async_get(hass) - if ( - alarm_control_panel_device_entry := device_registry.async_get(device_id) - ) is None: - raise vol.Invalid("Invalid device ID specified") - - assert alarm_control_panel_device_entry.via_device_id - - if ( - base_station_device_entry := device_registry.async_get( - alarm_control_panel_device_entry.via_device_id - ) - ) is None: - raise ValueError("No base station registered for alarm control panel") - - [system_id_str] = [ - identity[1] - for identity in base_station_device_entry.identifiers - if identity[0] == DOMAIN - ] - system_id = int(system_id_str) - - entry: SimpliSafeConfigEntry | None - for entry_id in base_station_device_entry.config_entries: - if ( - (entry := hass.config_entries.async_get_entry(entry_id)) is None - or entry.domain != DOMAIN - or entry.state is not ConfigEntryState.LOADED - ): - continue - return entry.runtime_data.systems[system_id] - - raise ValueError(f"No system for device ID: {device_id}") +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the integration.""" + async_setup_services(hass) + return True @callback @@ -295,7 +160,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) - """Set up SimpliSafe as config entry.""" _async_standardize_config_entry(hass, entry) - _verify_domain_control = verify_domain_control(DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) try: @@ -319,64 +183,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback - def extract_system( - func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]], - ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: - """Define a decorator to get the correct system for a service call.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - system = _async_get_system_for_service_call(hass, call) - - try: - await func(call, system) - except SimplipyError as err: - raise HomeAssistantError( - f'Error while executing "{call.service}": {err}' - ) from err - - return wrapper - - @_verify_domain_control - @extract_system - async def async_remove_pin(call: ServiceCall, system: SystemType) -> None: - """Remove a PIN.""" - await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) - - @_verify_domain_control - @extract_system - async def async_set_pin(call: ServiceCall, system: SystemType) -> None: - """Set a PIN.""" - await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) - - @_verify_domain_control - @extract_system - async def async_set_system_properties( - call: ServiceCall, system: SystemType - ) -> None: - """Set one or more system parameters.""" - if not isinstance(system, SystemV3): - raise HomeAssistantError("Can only set system properties on V3 systems") - - await system.async_set_properties( - {prop: value for prop, value in call.data.items() if prop != ATTR_DEVICE_ID} - ) - - for service, method, schema in ( - (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA), - (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA), - ( - SERVICE_NAME_SET_SYSTEM_PROPERTIES, - async_set_system_properties, - SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA, - ), - ): - if hass.services.has_service(DOMAIN, service): - continue - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service(hass, DOMAIN, service, method, schema=schema) - current_options = {**entry.options} async def async_reload_entry(_: HomeAssistant, updated_entry: ConfigEntry) -> None: @@ -403,15 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -> bool: """Unload a SimpliSafe config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if not hass.config_entries.async_loaded_entries(DOMAIN): - # If this is the last loaded instance of SimpliSafe, deregister any services - # defined during integration setup: - for service_name in SERVICES: - hass.services.async_remove(DOMAIN, service_name) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class SimpliSafe: diff --git a/homeassistant/components/simplisafe/services.py b/homeassistant/components/simplisafe/services.py new file mode 100644 index 000000000000..55c465ec1840 --- /dev/null +++ b/homeassistant/components/simplisafe/services.py @@ -0,0 +1,235 @@ +"""Services for SimpliSafe.""" + +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from simplipy.errors import SimplipyError +from simplipy.system.v3 import ( + MAX_ALARM_DURATION, + MAX_ENTRY_DELAY_AWAY, + MAX_ENTRY_DELAY_HOME, + MAX_EXIT_DELAY_AWAY, + MAX_EXIT_DELAY_HOME, + MIN_ALARM_DURATION, + MIN_ENTRY_DELAY_AWAY, + MIN_EXIT_DELAY_AWAY, + SystemV3, + Volume, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service import ( + async_register_admin_service, + verify_domain_control, +) + +from .const import ( + ATTR_ALARM_DURATION, + ATTR_ALARM_VOLUME, + ATTR_CHIME_VOLUME, + ATTR_ENTRY_DELAY_AWAY, + ATTR_ENTRY_DELAY_HOME, + ATTR_EXIT_DELAY_AWAY, + ATTR_EXIT_DELAY_HOME, + ATTR_LIGHT, + ATTR_VOICE_PROMPT_VOLUME, + DOMAIN, +) +from .typing import SystemType + +if TYPE_CHECKING: + from . import SimpliSafeConfigEntry + +ATTR_PIN_LABEL = "label" +ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" +ATTR_PIN_VALUE = "pin" + +VOLUME_MAP = { + "high": Volume.HIGH, + "low": Volume.LOW, + "medium": Volume.MEDIUM, + "off": Volume.OFF, +} + +SERVICE_NAME_REMOVE_PIN = "remove_pin" +SERVICE_NAME_SET_PIN = "set_pin" +SERVICE_NAME_SET_SYSTEM_PROPERTIES = "set_system_properties" + +SERVICE_REMOVE_PIN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string, + } +) + +SERVICE_SET_PIN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_PIN_LABEL): cv.string, + vol.Required(ATTR_PIN_VALUE): cv.string, + }, +) + +SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ALARM_DURATION): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION), + ), + vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), + vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), + vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY), + ), + vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(max=MAX_ENTRY_DELAY_HOME), + ), + vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY), + ), + vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(max=MAX_EXIT_DELAY_HOME), + ), + vol.Optional(ATTR_LIGHT): cv.boolean, + vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All( + vol.In(VOLUME_MAP), VOLUME_MAP.get + ), + } +) + +_verify_domain_control = verify_domain_control(DOMAIN) + + +@callback +def _async_get_system_for_service_call(call: ServiceCall) -> SystemType: + """Get the SimpliSafe system related to a service call (by device ID).""" + device_id = call.data[ATTR_DEVICE_ID] + device_registry = dr.async_get(call.hass) + + if ( + alarm_control_panel_device_entry := device_registry.async_get(device_id) + ) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + if TYPE_CHECKING: + assert alarm_control_panel_device_entry.via_device_id + + if ( + base_station_device_entry := device_registry.async_get( + alarm_control_panel_device_entry.via_device_id + ) + ) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_base_station", + translation_placeholders={"device_id": device_id}, + ) + + [system_id_str] = [ + identity[1] + for identity in base_station_device_entry.identifiers + if identity[0] == DOMAIN + ] + system_id = int(system_id_str) + + entry: SimpliSafeConfigEntry | None + for entry_id in base_station_device_entry.config_entries: + if ( + (entry := call.hass.config_entries.async_get_entry(entry_id)) is None + or entry.domain != DOMAIN + or entry.state is not ConfigEntryState.LOADED + ): + continue + return entry.runtime_data.systems[system_id] + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_system_for_device", + translation_placeholders={"device_id": device_id}, + ) + + +@callback +def extract_system( + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]], +) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: + """Define a decorator to get the correct system for a service call.""" + + async def wrapper(call: ServiceCall) -> None: + """Wrap the service function.""" + system = _async_get_system_for_service_call(call) + + try: + await func(call, system) + except SimplipyError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_while_executing", + translation_placeholders={"service": call.service, "error": str(err)}, + ) from err + + return wrapper + + +@_verify_domain_control +@extract_system +async def async_remove_pin(call: ServiceCall, system: SystemType) -> None: + """Remove a PIN.""" + await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) + + +@_verify_domain_control +@extract_system +async def async_set_pin(call: ServiceCall, system: SystemType) -> None: + """Set a PIN.""" + await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) + + +@_verify_domain_control +@extract_system +async def async_set_system_properties(call: ServiceCall, system: SystemType) -> None: + """Set one or more system parameters.""" + if not isinstance(system, SystemV3): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="set_system_properties_not_v3", + ) + + await system.async_set_properties( + {prop: value for prop, value in call.data.items() if prop != ATTR_DEVICE_ID} + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + + for service, method, schema in ( + (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA), + (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA), + ( + SERVICE_NAME_SET_SYSTEM_PROPERTIES, + async_set_system_properties, + SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA, + ), + ): + async_register_admin_service(hass, DOMAIN, service, method, schema=schema) diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index a8bbb8679865..2e858766e2e5 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -27,6 +27,23 @@ } } }, + "exceptions": { + "error_while_executing": { + "message": "Error while executing \"{service}\": {error}" + }, + "invalid_device_id": { + "message": "No device could be found for ID: {device_id}" + }, + "no_base_station": { + "message": "No base station could be found for device ID: {device_id}" + }, + "no_system_for_device": { + "message": "No SimpliSafe system could be found for device ID: {device_id}" + }, + "set_system_properties_not_v3": { + "message": "System properties can only be set on V3 systems." + } + }, "options": { "step": { "init": { From 35f5a210c1350e57a3934b8807e6c78c44071bee Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:05:35 +0200 Subject: [PATCH 472/707] Add manual token entry to enphase_envoy config flow (#166063) Co-authored-by: Joost Lekkerkerker --- .../components/enphase_envoy/config_flow.py | 196 ++++++--- .../components/enphase_envoy/const.py | 3 + .../components/enphase_envoy/coordinator.py | 78 +++- .../components/enphase_envoy/strings.json | 30 ++ tests/components/enphase_envoy/__init__.py | 17 + tests/components/enphase_envoy/conftest.py | 62 ++- .../snapshots/test_services.ambr | 6 + .../enphase_envoy/test_config_flow.py | 390 +++++++++++++++++- tests/components/enphase_envoy/test_init.py | 107 ++++- 9 files changed, 810 insertions(+), 79 deletions(-) create mode 100644 tests/components/enphase_envoy/snapshots/test_services.ambr diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 0babd95a2073..a317cfb121bf 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -5,7 +5,8 @@ import logging from typing import TYPE_CHECKING, Any, override from awesomeversion import AwesomeVersion -from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError +import jwt +from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError, EnvoyTokenAuth import voluptuous as vol from homeassistant.config_entries import ( @@ -25,8 +26,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType +from homeassistant.util import dt as dt_util from .const import ( + ACCESS_TOKEN_LOGIN_URL, + CONF_MANUAL_TOKEN, DOMAIN, INVALID_AUTH_ERRORS, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, @@ -46,17 +50,44 @@ INSTALLER_AUTH_USERNAME = "installer" AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN} +UNKNOWN_TOKEN_TEXT = "?" + def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]: """Return a dictionary without AVOID_REFLECT_KEYS.""" return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS} +def token_lifetime(token: str) -> str: + """Return token lifetime in days.""" + days_left = UNKNOWN_TOKEN_TEXT + try: + jwt_payload = jwt.decode(token, options={"verify_signature": False}) + exp = jwt_payload.get("exp") + if exp is not None: + days_left = str(int((int(exp) - dt_util.utcnow().timestamp()) / 86400)) + except jwt.PyJWTError, KeyError, TypeError, ValueError: + days_left = UNKNOWN_TOKEN_TEXT + return days_left + + +def descriptions( + serial: str, token_days_left: str = UNKNOWN_TOKEN_TEXT +) -> dict[str, str]: + """Build description placeholders.""" + return { + CONF_SERIAL: serial, + "enphase_url": ACCESS_TOKEN_LOGIN_URL, + "token_life": token_days_left, + } + + async def validate_input( hass: HomeAssistant, host: str, username: str, password: str, + token: str | None, errors: dict[str, str], description_placeholders: dict[str, str], ) -> Envoy: @@ -64,7 +95,7 @@ async def validate_input( envoy = Envoy(host, async_get_clientsession(hass, verify_ssl=False)) try: await envoy.setup() - await envoy.authenticate(username=username, password=password) + await envoy.authenticate(username=username, password=password, token=token) except INVALID_AUTH_ERRORS as e: errors["base"] = "invalid_auth" description_placeholders["reason"] = str(e) @@ -88,6 +119,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None self.username = None self.protovers: str | None = None + self.manual_token: bool = False @staticmethod @callback @@ -118,10 +150,18 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): ): default_username = INSTALLER_AUTH_USERNAME - schema[ - vol.Optional(CONF_USERNAME, default=self.username or default_username) - ] = str - schema[vol.Optional(CONF_PASSWORD, default="")] = str + if self.manual_token: + # in manual token entry mode show token input field + schema[vol.Optional(CONF_TOKEN, default="")] = str + else: + # in automatic token mode show username and password inputs + schema[ + vol.Optional(CONF_USERNAME, default=self.username or default_username) + ] = str + schema[vol.Optional(CONF_PASSWORD, default="")] = str + + # option to switch between automatic and manual token entry modes + schema[vol.Optional(CONF_MANUAL_TOKEN, default=self.manual_token)] = bool return vol.Schema(schema) @@ -194,28 +234,48 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} + token_days_left: str = UNKNOWN_TOKEN_TEXT - if user_input is not None: - await validate_input( + if token := reauth_entry.data.get(CONF_TOKEN, ""): + token_days_left = token_lifetime(token) + + if user_input is None: + # remember current manual_token setting to detect switch between modes + self.manual_token = reauth_entry.data.get(CONF_MANUAL_TOKEN, False) + elif user_input.get(CONF_MANUAL_TOKEN) != self.manual_token: + # user is switching between manual and automatic token entry mode + # display the form in the other mode, no configuration update yet + self.manual_token = user_input[CONF_MANUAL_TOKEN] + else: + envoy = await validate_input( self.hass, reauth_entry.data[CONF_HOST], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], + user_input.get(CONF_USERNAME, ""), + user_input.get(CONF_PASSWORD, ""), + token := user_input.get(CONF_TOKEN, "") or None, errors, description_placeholders, ) if not errors: + # successful authentication, update config return self.async_update_reload_and_abort( reauth_entry, - data_updates=user_input, + data_updates=user_input + | ( + {CONF_TOKEN: envoy.auth.token} + if isinstance(envoy.auth, EnvoyTokenAuth) + else {} + ), ) + if token: + token_days_left = token_lifetime(token) serial = reauth_entry.unique_id or "-" self.context["title_placeholders"] = { CONF_SERIAL: serial, CONF_HOST: reauth_entry.data[CONF_HOST], } - description_placeholders["serial"] = serial + description_placeholders.update(descriptions(serial, token_days_left)) return self.async_show_form( step_id="reauth_confirm", data_schema=self.add_suggested_values_to_schema( @@ -238,44 +298,66 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" + token_days_left: str = UNKNOWN_TOKEN_TEXT + + if user_input and (token := user_input.get(CONF_TOKEN, "")): + token_days_left = token_lifetime(token) if user_input is not None: - envoy = await validate_input( - self.hass, - host, - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - errors, - description_placeholders, - ) - if not errors: - name = self._async_envoy_name() - - if not self.unique_id: - await self.async_set_unique_id(envoy.serial_number) + if ( + manual_mode := user_input.get(CONF_MANUAL_TOKEN, False) + ) != self.manual_token: + # for new config self.manual_token starts default as false + # user is switching between manual and automatic token entry mode + # show form again in other mode, no configuration update yet + self.manual_token = manual_mode + else: + envoy = await validate_input( + self.hass, + host, + user_input.get(CONF_USERNAME, ""), + user_input.get(CONF_PASSWORD, ""), + token := user_input.get(CONF_TOKEN, "") or None, + errors, + description_placeholders, + ) + if not errors: name = self._async_envoy_name() - - if self.unique_id: - # If envoy exists in configuration update fields and exit - self._abort_if_unique_id_configured( - { - CONF_HOST: host, - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - }, - error="reauth_successful", + # successful authentication, store token in config + token_update = ( + {CONF_TOKEN: envoy.auth.token} + if isinstance(envoy.auth, EnvoyTokenAuth) + else {} ) - # CONF_NAME is still set for legacy backwards compatibility - return self.async_create_entry( - title=name, data={CONF_HOST: host, CONF_NAME: name} | user_input - ) + if not self.unique_id: + await self.async_set_unique_id(envoy.serial_number) + name = self._async_envoy_name() + + if self.unique_id: + # If envoy exists in configuration update fields and exit + self._abort_if_unique_id_configured( + { + CONF_HOST: host, + CONF_USERNAME: user_input.get(CONF_USERNAME, ""), + CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""), + CONF_MANUAL_TOKEN: self.manual_token, + } + | token_update, + error="reauth_successful", + ) + + # CONF_NAME is still set for legacy backwards compatibility + return self.async_create_entry( + title=name, data={CONF_NAME: name} | user_input | token_update + ) if self.unique_id: self.context["title_placeholders"] = { CONF_SERIAL: self.unique_id, CONF_HOST: host, } + description_placeholders.update(descriptions("", token_days_left)) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( @@ -293,20 +375,29 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): reconfigure_entry = self._get_reconfigure_entry() errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} + token_days_left: str = UNKNOWN_TOKEN_TEXT - if user_input is not None: - host: str = user_input[CONF_HOST] - username: str = user_input[CONF_USERNAME] - password: str = user_input[CONF_PASSWORD] + if token := reconfigure_entry.data.get(CONF_TOKEN, ""): + token_days_left = token_lifetime(token) + if user_input is None: + # remember current manual_token setting to detect switch between modes + self.manual_token = reconfigure_entry.data.get(CONF_MANUAL_TOKEN, False) + elif user_input.get(CONF_MANUAL_TOKEN) != self.manual_token: + # user switches between manual and automatic token entry mode + # show form again on other mode, no configuration update yet + self.manual_token = user_input[CONF_MANUAL_TOKEN] + else: envoy = await validate_input( self.hass, - host, - username, - password, + host := user_input[CONF_HOST], + username := user_input.get(CONF_USERNAME, ""), + password := user_input.get(CONF_PASSWORD, ""), + token := user_input.get(CONF_TOKEN, "") or None, errors, description_placeholders, ) if not errors: + # successful authentication, store token in config await self.async_set_unique_id(envoy.serial_number) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( @@ -315,16 +406,23 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: host, CONF_USERNAME: username, CONF_PASSWORD: password, - }, + CONF_MANUAL_TOKEN: self.manual_token, + } + | ( + {CONF_TOKEN: envoy.auth.token} + if isinstance(envoy.auth, EnvoyTokenAuth) + else {} + ), ) + if token: + token_days_left = token_lifetime(token) serial = reconfigure_entry.unique_id or "-" self.context["title_placeholders"] = { CONF_SERIAL: serial, CONF_HOST: reconfigure_entry.data[CONF_HOST], } - description_placeholders["serial"] = serial - + description_placeholders.update(descriptions(serial, token_days_left)) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index d5f46a66650a..95b66c9fede7 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -16,6 +16,9 @@ PLATFORMS = [ INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) +ACCESS_TOKEN_LOGIN_URL = "https://entrez.enphaseenergy.com" +CONF_MANUAL_TOKEN = "use_manual_token" + SETUP_RETRY_TIMEOUT = 50 OPERATIONAL_RETRY_TIMEOUT = 200 diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index a280e4833cfd..3549c2f7c3c8 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -4,6 +4,7 @@ import contextlib import datetime from datetime import timedelta import logging +import math from typing import Any, override from pyenphase import Envoy, EnvoyError, EnvoyTokenAuth @@ -13,12 +14,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ( + CONF_MANUAL_TOKEN, DOMAIN, INVALID_AUTH_ERRORS, OPERATIONAL_RETRY_TIMEOUT, @@ -28,8 +30,8 @@ from .const import ( SCAN_INTERVAL = timedelta(seconds=60) TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) -STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() -NOTIFICATION_ID = "enphase_envoy_notification" +STALE_TOKEN_THRESHOLD = 30 # days +TOKEN_REPAIR_ID = "enphase_envoy_token_expiry" FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4) MAC_VERIFICATION_DELAY = timedelta(seconds=34) _LOGGER = logging.getLogger(__name__) @@ -45,6 +47,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): envoy_firmware: str config_entry: EnphaseConfigEntry interface: EnvoyInterfaceInformation | None + token_lifetime: int # days of token life left def __init__( self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry @@ -52,8 +55,9 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize DataUpdateCoordinator for the envoy.""" self.envoy = envoy entry_data = entry.data - self.username = entry_data[CONF_USERNAME] - self.password = entry_data[CONF_PASSWORD] + self.username = entry_data.get(CONF_USERNAME) + self.password = entry_data.get(CONF_PASSWORD) + self.manual_token = entry_data.get(CONF_MANUAL_TOKEN, False) self._setup_complete = False self._operational_timeout = False self.envoy_firmware = "" @@ -61,6 +65,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._cancel_token_refresh: CALLBACK_TYPE | None = None self._cancel_firmware_refresh: CALLBACK_TYPE | None = None self._cancel_mac_verification: CALLBACK_TYPE | None = None + self.token_lifetime = 0 super().__init__( hass, _LOGGER, @@ -70,19 +75,69 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) + def _track_token_lifetime(self) -> bool: + """Update tokenlifetime and return if still fresh.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + self.token_lifetime = max( + 0, + math.ceil( + (self.envoy.auth.expire_timestamp - dt_util.utcnow().timestamp()) + / 86400 + ), + ) + return self.token_lifetime > STALE_TOKEN_THRESHOLD + @callback def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None: """Proactively refresh token if its stale in case cloud services goes down.""" assert isinstance(self.envoy.auth, EnvoyTokenAuth) - expire_time = self.envoy.auth.expire_timestamp - remain = expire_time - now.timestamp() - fresh = remain > STALE_TOKEN_THRESHOLD + fresh = self._track_token_lifetime() name = self.name - _LOGGER.debug("%s: %s seconds remaining on token fresh=%s", name, remain, fresh) + _LOGGER.debug( + "%s: %s days remaining on token, fresh=%s, manual token mode=%s", + name, + self.token_lifetime, + fresh, + self.manual_token, + ) if not fresh: - self.hass.async_create_background_task( - self._async_try_refresh_token(), "{name} token refresh" + if not self.manual_token: + self.hass.async_create_background_task( + self._async_try_refresh_token(), f"{name} token refresh" + ) + return + + # User configured manual token entry, warn for upcoming expiry by issuing a repair + _LOGGER.debug( + "Create repair issue for %s token expiry in %s days", + self.name, + self.token_lifetime, ) + # Force issue rering each day until resolved by user + ir.async_delete_issue( + self.hass, DOMAIN, f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}" + ) + ir.async_create_issue( + self.hass, + domain=DOMAIN, + issue_id=f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="token_expiry", + translation_placeholders={ + "token_lifetime": str(self.token_lifetime), + "name": self.name, + }, + learn_more_url="https://www.home-assistant.io/integrations/enphase_envoy", + ) + return + if not self.manual_token: + return + # remove any repair that warned user to refresh manual token + ir.async_delete_issue( + self.hass, DOMAIN, f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}" + ) async def _async_try_refresh_token(self) -> None: """Try to refresh token.""" @@ -256,6 +311,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # startup without hitting the Cloud API # as long as the token is valid _LOGGER.debug("%s: Updating token in config entry from auth", self.name) + self._track_token_lifetime() self.hass.config_entries.async_update_entry( self.config_entry, data={ diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 12ce059967b8..02ff75664d9f 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -16,10 +16,14 @@ "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data::use_manual_token%]", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data_description::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data_description::use_manual_token%]", "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]" }, "description": "[%key:component::enphase_envoy::config::step::user::description%]" @@ -28,11 +32,15 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data::use_manual_token%]", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]", "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data_description::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data_description::use_manual_token%]", "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]" }, "description": "[%key:component::enphase_envoy::config::step::user::description%]" @@ -41,11 +49,15 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", + "token": "Envoy access token", + "use_manual_token": "Enter the Envoy access token manually", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { "host": "The hostname or IP address of your Enphase Envoy gateway.", "password": "Blank or Enphase Cloud password", + "token": "Go to the [Enphase login]({enphase_url}) to get a new token. Current token lifetime: {token_life} days.", + "use_manual_token": "If your Enphase Cloud account has multi-factor authentication enabled, check this option to add or update your token manually.", "username": "Installer or Enphase Cloud username" }, "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models, enter username `installer` without a password." @@ -723,10 +735,28 @@ "envoy_error": { "message": "Error communicating with Envoy API on {host}: {args}" }, + "envoy_token_lifetime_service_envoy_not_found": { + "message": "No Envoy found by token lifetime action for device ID {device_id}." + }, + "envoy_token_lifetime_service_no_device_id": { + "message": "No Envoy found by token lifetime action. Configure an Envoy or specify a `device_id` if more than 1 Envoy is configured." + }, + "no_token_auth": { + "message": "{service}: Envoy token authorization is only used with firmware version 7.0 and later." + }, + "not_initialized": { + "message": "{service}: Enphase Envoy is not yet initialized" + }, "unexpected_device": { "message": "Unexpected Envoy serial number found at {host}; expected {expected_serial}, found {actual_serial}" } }, + "issues": { + "token_expiry": { + "description": "The {name} access token expires in {token_lifetime} days and is configured for manual token entry. Make sure to enter a new token before the final expiry day. To update the token, go to the Home Assistant Enphase Envoy integration page and use the reconfigure menu option.", + "title": "{name} access token expires in {token_lifetime} days." + } + }, "options": { "step": { "init": { diff --git a/tests/components/enphase_envoy/__init__.py b/tests/components/enphase_envoy/__init__.py index f5381eda2a78..199d950c02a2 100644 --- a/tests/components/enphase_envoy/__init__.py +++ b/tests/components/enphase_envoy/__init__.py @@ -1,7 +1,12 @@ """Tests for the Enphase Envoy integration.""" +from datetime import timedelta + +from jwt import encode + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -17,3 +22,15 @@ async def setup_integration( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is expected_state + + +def envoy_token(days_to_expiry: int = 365) -> str: + """Build envoy token with specified days to expiration.""" + return encode( + payload={ + "name": "envoy", + "exp": (dt_util.utcnow() + timedelta(days=days_to_expiry)).timestamp(), + }, + key="secret", + algorithm="HS256", + ) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index f112c78a7567..94919d6fe860 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -4,7 +4,6 @@ from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch -import jwt import multidict from pyenphase import ( EnvoyACBPower, @@ -29,9 +28,18 @@ from pyenphase.models.tariff import EnvoyStorageSettings, EnvoyTariff import pytest from homeassistant.components.enphase_envoy import DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.enphase_envoy.const import CONF_MANUAL_TOKEN +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant +from . import envoy_token + from tests.common import MockConfigEntry, load_json_object_fixture @@ -47,15 +55,49 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="config_entry") def config_entry_fixture( - hass: HomeAssistant, config: dict[str, str] + hass: HomeAssistant, + config: dict[str, Any], + request: pytest.FixtureRequest, ) -> MockConfigEntry: """Define a config entry fixture.""" + token_mode = "none" + token_life = 365 + if hasattr(request, "param"): + if isinstance(request.param, list): + token_mode = request.param[0] + if len(request.param) > 1: + token_life = request.param[1] + else: + token_mode = request.param + if token_mode == "none": + data = config + elif token_mode == "auto": + # config contains token from automatic retrieval + data = { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: envoy_token(token_life), + CONF_MANUAL_TOKEN: False, + } + elif token_mode == "manual": + # config contains token from manual entry + data = { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_TOKEN: envoy_token(token_life), + CONF_MANUAL_TOKEN: True, + } + return MockConfigEntry( domain=DOMAIN, entry_id="45a36e55aaddb2007c5f6602e0c38e72", title="Envoy 1234", unique_id="1234", - data=config, + data=data, ) @@ -75,11 +117,7 @@ async def mock_envoy( request: pytest.FixtureRequest, ) -> AsyncGenerator[AsyncMock]: """Define a mocked Envoy fixture.""" - new_token = jwt.encode( - payload={"name": "envoy", "exp": 2007837780}, - key="secret", - algorithm="HS256", - ) + new_token = envoy_token() with ( patch( "homeassistant.components.enphase_envoy.config_flow.Envoy", @@ -96,11 +134,7 @@ async def mock_envoy( ): mock_envoy = mock_client.return_value # Add the fixtures specified - token = jwt.encode( - payload={"name": "envoy", "exp": 1907837780}, - key="secret", - algorithm="HS256", - ) + token = envoy_token(200) mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") mock_envoy.serial_number = "1234" mock = Mock() diff --git a/tests/components/enphase_envoy/snapshots/test_services.ambr b/tests/components/enphase_envoy/snapshots/test_services.ambr new file mode 100644 index 000000000000..620d07cc389d --- /dev/null +++ b/tests/components/enphase_envoy/snapshots/test_services.ambr @@ -0,0 +1,6 @@ +# serializer version: 1 +# name: test_has_services + list([ + 'token_lifetime', + ]) +# --- diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 52a2e3716d78..fc149036c430 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -4,10 +4,11 @@ from ipaddress import ip_address import logging from unittest.mock import AsyncMock -from pyenphase import EnvoyAuthenticationError, EnvoyError +from pyenphase import EnvoyAuthenticationError, EnvoyError, EnvoyTokenAuth import pytest from homeassistant.components.enphase_envoy.const import ( + CONF_MANUAL_TOKEN, DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES_DEFAULT_VALUE, @@ -15,12 +16,18 @@ from homeassistant.components.enphase_envoy.const import ( OPTION_DISABLE_KEEP_ALIVE_DEFAULT_VALUE, ) from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import setup_integration +from . import envoy_token, setup_integration from tests.common import MockConfigEntry @@ -52,6 +59,8 @@ async def test_form(hass: HomeAssistant, mock_envoy: AsyncMock) -> None: CONF_NAME: "Envoy 1234", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + CONF_TOKEN: mock_envoy.auth.token, } @@ -83,6 +92,9 @@ async def test_user_no_serial_number( CONF_NAME: "Envoy", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + # mock always fills token + CONF_TOKEN: mock_envoy.auth.token, } @@ -186,6 +198,8 @@ async def test_zeroconf( CONF_NAME: "Envoy 1234", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + CONF_TOKEN: mock_envoy.auth.token, } @@ -814,3 +828,373 @@ async def test_reconfigure_change_ip_to_existing( assert other_entry.data[CONF_HOST] == "1.1.1.2" assert other_entry.data[CONF_USERNAME] == "other-username" assert other_entry.data[CONF_PASSWORD] == "other-password" + + +async def test_form_configure_manual_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test user step selecting to use manual token entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # user opts for manual token entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # in manual token mode user enters host, token with error and leaves manual_token on + token = envoy_token() + wrong_token = "wrongtoken" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", token=wrong_token, envoy_serial="1234" + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True, CONF_TOKEN: wrong_token}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # user input to update manual token + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + mock_envoy.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_TOKEN: token, CONF_MANUAL_TOKEN: True}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Envoy 1234" + assert result["result"].unique_id == "1234" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_MANUAL_TOKEN: True, + CONF_TOKEN: token, + } + + +async def test_form_switch_between_token_modes( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test user step selecting to use automatic token entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # user opts for manual token entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # in manual token mode user opts to switch back to automatic_token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: False}, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # user enters credentials and leaves manual_token off + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Envoy 1234" + assert result["result"].unique_id == "1234" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + # in auto mode the verification will retrieve token from envoy + CONF_TOKEN: mock_envoy.auth.token, + } + + +async def test_reauth_switch_to_manual_token( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test reauth switch to manual token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # user opts to switch to manual token + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {} + + # user enters token + token = envoy_token() + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_TOKEN: token, + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_TOKEN] == token + assert config_entry.data[CONF_MANUAL_TOKEN] + + +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +async def test_reauth_manual_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reauth in manual token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # user input to update manual token with token error + next_token = envoy_token(300) + wrong_token = "wrongtoken" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", token=wrong_token, envoy_serial="1234" + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_MANUAL_TOKEN: True, CONF_TOKEN: wrong_token}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # user input to update manual token + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=next_token, envoy_serial="1234") + mock_envoy.authenticate.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: next_token, + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_MANUAL_TOKEN] + assert config_entry.data[CONF_TOKEN] == next_token + + +@pytest.mark.parametrize( + ("config_entry"), + [("auto")], + indirect=True, +) +async def test_reconfigure_switch_to_manual_token( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test reconfigure form switching from automatic to manual token entry.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # user input to switch to manual token entry + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True}, + ) + await hass.async_block_till_done() + # no config update, only form mode switch + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {} + + # in manual token mode user enters host, token and manual_token option + token = envoy_token() + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_TOKEN: token, CONF_MANUAL_TOKEN: True}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Now we should have token and manual_token mode on + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_USERNAME] == "" + assert config_entry.data[CONF_PASSWORD] == "" + assert config_entry.data[CONF_TOKEN] == token + assert config_entry.data[CONF_MANUAL_TOKEN] + + +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +async def test_reconfigure_manual_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure in manual token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # user input to update manual token with token error + next_token = envoy_token(300) + wrong_token = "wrongtoken" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", token=wrong_token, envoy_serial="1234" + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True, CONF_TOKEN: wrong_token}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "invalid_auth"} + + # user input to update manual token + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=next_token, envoy_serial="1234") + mock_envoy.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True, CONF_TOKEN: next_token}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_USERNAME] == "" + assert config_entry.data[CONF_PASSWORD] == "" + assert config_entry.data[CONF_TOKEN] == next_token + assert config_entry.data[CONF_MANUAL_TOKEN] + + +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +async def test_reconfigure_switch_from_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure switching back to automatic token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # user input to switch from manual to automatic token entry + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: False}, + ) + + await hass.async_block_till_done() + # no config update, only switch to other form mode + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {} + + # user updates username & pw + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username1", + CONF_PASSWORD: "test-password2", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # # token should be automatic again and manual_token false + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_USERNAME] == "test-username1" + assert config_entry.data[CONF_PASSWORD] == "test-password2" + assert config_entry.data[CONF_TOKEN] == mock_envoy.auth.token + assert not config_entry.data[CONF_MANUAL_TOKEN] diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 3609e811e526..bf2cc90f9fb4 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -23,6 +23,7 @@ from homeassistant.components.enphase_envoy.coordinator import ( FIRMWARE_REFRESH_INTERVAL, MAC_VERIFICATION_DELAY, SCAN_INTERVAL, + TOKEN_REPAIR_ID, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -34,12 +35,16 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -133,6 +138,104 @@ async def test_expired_token_in_config( assert entity_state.state == "116" +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +@respx.mock +async def test_not_expired_token_with_manual_token( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator with 365 day token in config and manual token option on.""" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", + token=config_entry.data[CONF_TOKEN], + envoy_serial="1234", + ) + caplog.set_level(logging.DEBUG) + + await setup_integration(hass, config_entry) + + assert ( + "Envoy 1234: 365 days remaining on token, fresh=True, manual token mode=True" + in caplog.text + ) + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "116" + + +@pytest.mark.parametrize( + ("config_entry"), + [["manual", 25]], # expires in 25 days + indirect=True, +) +@respx.mock +async def test_almost_expired_token_with_manual_token( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator with token within warning time in config and manual token option on.""" + caplog.set_level(logging.DEBUG) + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + + # Make sure to mock pyenphase.auth.EnvoyTokenAuth._obtain_token + # when specifying username and password in EnvoyTokenauth + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", + token=config_entry.data[CONF_TOKEN], + envoy_serial="1234", + ) + await setup_integration( + hass, + config_entry, + ) + assert ( + "Envoy 1234: 25 days remaining on token, fresh=False, manual token mode=True" + in caplog.text + ) + + # verify repair was issued + assert len(events) == 1 + assert events[0].data == { + "action": "create", + "domain": DOMAIN, + "issue_id": f"{TOKEN_REPAIR_ID}_1234", + } + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "116" + + +@pytest.mark.parametrize( + ("config_entry"), + [["manual", -1]], # expired yesterday + indirect=True, +) +@respx.mock +async def test_expired_token_with_manual_token( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator with expired token in config and manual token option on.""" + + # manual token mode has no option to use username/pw to refresh token + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError( + "fail authentication" + ) + # setup should fail with expired token + await setup_integration( + hass, config_entry, expected_state=ConfigEntryState.SETUP_ERROR + ) + + async def test_coordinator_update_error( hass: HomeAssistant, mock_envoy: AsyncMock, From 9cd694008baf1222fc3815f9c0d9303dc583c46f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:46:45 +0200 Subject: [PATCH 473/707] Migrate input_number entity attributes to StrEnum (#175740) --- homeassistant/components/input_number/__init__.py | 9 +++++---- homeassistant/components/input_number/const.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/input_number/const.py diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 7e9b2c9e054a..8558f203d12a 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.number import NumberEntity from homeassistant.const import ( # noqa: F401 - ATTR_EDITABLE, ATTR_MODE, CONF_ICON, CONF_ID, @@ -25,6 +24,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputNumberEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "input_number" @@ -204,7 +205,7 @@ class NumberStorageCollection(collection.DictStorageCollection): class InputNumber(collection.CollectionEntity, NumberEntity, RestoreEntity): """Representation of a slider.""" - _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _unrecorded_attributes = frozenset({InputNumberEntityStateAttribute.EDITABLE}) _attr_should_poll = False editable: bool @@ -248,8 +249,8 @@ class InputNumber(collection.CollectionEntity, NumberEntity, RestoreEntity): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { - ATTR_INITIAL: self._initial_value, - ATTR_EDITABLE: self.editable, + InputNumberEntityStateAttribute.INITIAL: self._initial_value, + InputNumberEntityStateAttribute.EDITABLE: self.editable, } @override diff --git a/homeassistant/components/input_number/const.py b/homeassistant/components/input_number/const.py new file mode 100644 index 000000000000..c052ef8afdaa --- /dev/null +++ b/homeassistant/components/input_number/const.py @@ -0,0 +1,10 @@ +"""Constants for the input_number integration.""" + +from enum import StrEnum + + +class InputNumberEntityStateAttribute(StrEnum): + """State attributes for input number entities.""" + + INITIAL = "initial" + EDITABLE = "editable" From e4167db4ce00b671ecd0e641c7644963d00c8d84 Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 11 Jul 2026 15:05:16 +1000 Subject: [PATCH 474/707] Bump pysmlight to 0.5.3 (#176252) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index e43028669e73..8a818fccfd8f 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["pysmlight==0.5.2", "bleak-smlight==1.1.0"], + "requirements": ["pysmlight==0.5.3", "bleak-smlight==1.1.0"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 1a6f12100f86..4f8c917b077f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2586,7 +2586,7 @@ pysmhi==2.0.0 pysml==0.1.8 # homeassistant.components.smlight -pysmlight==0.5.2 +pysmlight==0.5.3 # homeassistant.components.snmp pysnmp==7.1.27 From e5f5667bfc8215484f17acecd3b6ae03e40b1f65 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:12:58 +0200 Subject: [PATCH 475/707] Bump uiprotect to 15.7.1 (#176260) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e6b3838cc0a3..7109260ffc32 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.6.0"] + "requirements": ["uiprotect==15.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f8c917b077f..18614c26ef08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3255,7 +3255,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.6.0 +uiprotect==15.7.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From 4359c8177526e483bda1331197f7281936d84e41 Mon Sep 17 00:00:00 2001 From: Radu Ursache <3800336+rursache@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:59:43 +0300 Subject: [PATCH 476/707] Handle missing uptime for stopped Proxmox VMs and LXCs (#176232) --- homeassistant/components/proxmoxve/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index a886b5af24cc..5701473fc8c4 100644 --- a/homeassistant/components/proxmoxve/sensor.py +++ b/homeassistant/components/proxmoxve/sensor.py @@ -230,7 +230,7 @@ VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = ( ProxmoxVMSensorEntityDescription( key="vm_uptime", translation_key="vm_uptime", - value_fn=lambda data: data["uptime"], + value_fn=lambda data: data.get("uptime"), device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, @@ -347,7 +347,7 @@ CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = ( ProxmoxContainerSensorEntityDescription( key="container_uptime", translation_key="container_uptime", - value_fn=lambda data: data["uptime"], + value_fn=lambda data: data.get("uptime"), device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, From b55658fb0de813486068355c932e020705fdd9dc Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 11 Jul 2026 12:17:45 +0200 Subject: [PATCH 477/707] Add Reolink pre-siren entities (#176186) --- homeassistant/components/reolink/button.py | 6 ++++++ homeassistant/components/reolink/icons.json | 6 ++++++ homeassistant/components/reolink/strings.json | 6 ++++++ homeassistant/components/reolink/switch.py | 10 ++++++++++ .../components/reolink/snapshots/test_diagnostics.ambr | 4 ++-- 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 094cc9194021..7888be0fffca 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -172,6 +172,12 @@ BUTTON_ENTITIES = ( supported=lambda api, ch: api.supported(ch, "reboot"), method=lambda api, ch: api.reboot(ch), ), + ReolinkButtonEntityDescription( + key="pre_siren", + translation_key="pre_siren", + supported=lambda api, ch: api.supported(ch, "pre_siren"), + method=lambda api, ch: api.baichuan.PreAlarm(ch), + ), ) HOST_BUTTON_ENTITIES = ( diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index a5716aa76d20..7f5822f8d2a6 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -141,6 +141,9 @@ "guard_set": { "default": "mdi:crosshairs-gps" }, + "pre_siren": { + "default": "mdi:alarm-light" + }, "ptz_auto": { "default": "mdi:infinity" }, @@ -584,6 +587,9 @@ "pre_record": { "default": "mdi:history" }, + "pre_siren_on_event": { + "default": "mdi:alarm-light" + }, "privacy_mask": { "default": "mdi:eye", "state": { diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index e88da2fbbaff..5df5df9c4136 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -203,6 +203,9 @@ "guard_set": { "name": "Guard set current position" }, + "pre_siren": { + "name": "Pre-siren" + }, "ptz_auto": { "name": "PTZ continuous rotation" }, @@ -847,6 +850,9 @@ "pre_record": { "name": "Pre-recording" }, + "pre_siren_on_event": { + "name": "Pre-siren on event" + }, "privacy_mask": { "name": "Privacy mask" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 69be215d546d..2d9dbf3a73b6 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -101,6 +101,16 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.audio_alarm_enabled(ch), method=lambda api, ch, value: api.set_audio_alarm(ch, value), ), + ReolinkSwitchEntityDescription( + key="pre_siren_on_event", + cmd_key="GetAudioCfg", + cmd_id=264, + translation_key="pre_siren_on_event", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "pre_siren"), + value=lambda api, ch: api.pre_alarm_enabled(ch), + method=lambda api, ch, value: api.set_pre_alarm(ch, value), + ), ReolinkSwitchEntityDescription( key="auto_tracking", cmd_key="GetAiCfg", diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 6bbaa50ffa5c..77628e28f682 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -113,8 +113,8 @@ 'null': 1, }), 'GetAudioCfg': dict({ - '0': 4, - 'null': 4, + '0': 5, + 'null': 5, }), 'GetAutoFocus': dict({ '0': 1, From 3f71f69386e827f14dda2d402d661c28445b607f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jul 2026 02:21:02 -1000 Subject: [PATCH 478/707] Keep a stored HomeKit Thermostat choice when a pairing looks new (#176229) --- .../components/homekit/accessories.py | 12 +- .../components/homekit/test_accessory_type.py | 60 ++++++-- tests/components/homekit/test_config_flow.py | 130 ++++++++++++------ 3 files changed, 146 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 5215c4128cb7..88cd6b276dbf 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -215,7 +215,8 @@ def _async_resolve_climate_type( # one, so every path that sets a type defers persistence until # the accessory exists. return cast(str, climate_type) - if aid_storage.get_accessory_type(entity_id) == TYPE_HEATER_COOLER: + stored_type = aid_storage.get_accessory_type(entity_id) + if stored_type == TYPE_HEATER_COOLER: if not climate_controls_target_humidity(state): conf[CONF_TYPE] = TYPE_HEATER_COOLER return None @@ -224,7 +225,14 @@ def _async_resolve_climate_type( aid_storage.async_set_accessory_type(entity_id, None) if not climate_supports_heater_cooler(state): return None - if allow_auto and not aid_storage.entity_is_allocated(entity_id): + if ( + stored_type is None + and allow_auto + and not aid_storage.entity_is_allocated(entity_id) + ): + # A stored Thermostat choice survives even when the entity looks + # new again, like an accessory mode pairing reset, so Automatic + # keeps the accessory the entity already uses. conf[CONF_TYPE] = TYPE_HEATER_COOLER return TYPE_HEATER_COOLER return None diff --git a/tests/components/homekit/test_accessory_type.py b/tests/components/homekit/test_accessory_type.py index bb019a5137ef..3f9ae443a967 100644 --- a/tests/components/homekit/test_accessory_type.py +++ b/tests/components/homekit/test_accessory_type.py @@ -21,6 +21,8 @@ from homeassistant.components.homekit.const import ( HOMEKIT_MODE_BRIDGE, PERSIST_LOCK_DATA, ) +from homeassistant.components.homekit.type_heater_coolers import HeaterCooler +from homeassistant.components.homekit.type_thermostats import Thermostat from homeassistant.components.homekit.util import get_aid_storage_filename_for_entry_id from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant @@ -144,7 +146,7 @@ async def test_existing_entity_stays_thermostat( accessories = list(homekit.bridge.accessories.values()) assert len(accessories) == 1 - assert type(accessories[0]).__name__ == "Thermostat" + assert isinstance(accessories[0], Thermostat) await _async_stop_bridge(homekit) @@ -163,7 +165,7 @@ async def test_new_entity_routes_to_heater_cooler( accessories = list(homekit.bridge.accessories.values()) assert len(accessories) == 1 - assert type(accessories[0]).__name__ == "HeaterCooler" + assert isinstance(accessories[0], HeaterCooler) await _async_stop_bridge(homekit) @@ -215,7 +217,7 @@ async def test_failed_accessory_creation_is_not_recorded( # the automatic choice homekit = await _async_start_bridge(hass, entry) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "HeaterCooler" + assert isinstance(accessories[0], HeaterCooler) await _async_stop_bridge(homekit) @@ -232,14 +234,14 @@ async def test_heater_cooler_choice_survives_restart( homekit = await _async_start_bridge(hass, entry) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "HeaterCooler" + assert isinstance(accessories[0], HeaterCooler) await _async_stop_bridge(homekit) # The entity now has an aid allocation, so only the stored choice # keeps it on the HeaterCooler after a restart. homekit = await _async_start_bridge(hass, entry) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "HeaterCooler" + assert isinstance(accessories[0], HeaterCooler) await _async_stop_bridge(homekit) @@ -256,7 +258,7 @@ async def test_gained_humidity_setpoint_drops_stored_choice( homekit = await _async_start_bridge(hass, entry) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "HeaterCooler" + assert isinstance(accessories[0], HeaterCooler) await _async_stop_bridge(homekit) # The entity gains a humidity setpoint, which the HeaterCooler cannot @@ -268,7 +270,7 @@ async def test_gained_humidity_setpoint_drops_stored_choice( ) homekit = await _async_start_bridge(hass, entry) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "Thermostat" + assert isinstance(accessories[0], Thermostat) assert homekit.aid_storage is not None assert homekit.aid_storage.get_accessory_type(ENTITY_ID) is None await _async_stop_bridge(homekit) @@ -288,7 +290,7 @@ async def test_automatic_keeps_explicit_choice( # A new entity picks the HeaterCooler and the choice is stored homekit = await _async_start_bridge(hass, entry) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "HeaterCooler" + assert isinstance(accessories[0], HeaterCooler) await _async_stop_bridge(homekit) # An explicit Thermostat overrides and updates the stored routing @@ -296,14 +298,14 @@ async def test_automatic_keeps_explicit_choice( hass, entry, {ENTITY_ID: {CONF_TYPE: "thermostat"}} ) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "Thermostat" + assert isinstance(accessories[0], Thermostat) await _async_stop_bridge(homekit) # Back on automatic the entity keeps the Thermostat instead of # flipping back to the HeaterCooler homekit = await _async_start_bridge(hass, entry) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "Thermostat" + assert isinstance(accessories[0], Thermostat) await _async_stop_bridge(homekit) @@ -322,7 +324,37 @@ async def test_accessory_mode_existing_pairing_stays_thermostat( hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY, existing_pairing=True ) - assert type(homekit.driver.accessory).__name__ == "Thermostat" + assert isinstance(homekit.driver.accessory, Thermostat) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_stored_thermostat_survives_pairing_reset( + hass: HomeAssistant, +) -> None: + """Test a stored Thermostat choice is kept when the entity looks new.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + # An explicit Thermostat choice is stored with the accessory + homekit = await _async_start_bridge( + hass, + entry, + {ENTITY_ID: {CONF_TYPE: "thermostat"}}, + homekit_mode=HOMEKIT_MODE_ACCESSORY, + ) + assert isinstance(homekit.driver.accessory, Thermostat) + await _async_stop_bridge(homekit) + + # A pairing reset makes the entry look brand new, but Automatic still + # keeps the stored Thermostat instead of flipping to the HeaterCooler + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY + ) + assert isinstance(homekit.driver.accessory, Thermostat) await _async_stop_bridge(homekit) @@ -341,7 +373,7 @@ async def test_accessory_mode_new_pairing_routes_heater_cooler( hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY ) - assert type(homekit.driver.accessory).__name__ == "HeaterCooler" + assert isinstance(homekit.driver.accessory, HeaterCooler) await _async_stop_bridge(homekit) @@ -370,7 +402,7 @@ async def test_explicit_heater_cooler_wins_over_humidity_safeguard( ) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "HeaterCooler" + assert isinstance(accessories[0], HeaterCooler) await _async_stop_bridge(homekit) @@ -395,5 +427,5 @@ async def test_explicit_type_wins_for_existing_entity( ) accessories = list(homekit.bridge.accessories.values()) - assert type(accessories[0]).__name__ == "Thermostat" + assert isinstance(accessories[0], Thermostat) await _async_stop_bridge(homekit) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 613f6876f63b..1a3541ed6a1d 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1,11 +1,13 @@ """Test the HomeKit config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( CONF_FILTER, DOMAIN, @@ -1739,58 +1741,106 @@ async def test_options_flow_cameras_step_with_whole_domain_included( await hass.config_entries.async_unload(config_entry.entry_id) -@pytest.mark.parametrize("homekit_mode", ["bridge", "accessory"]) +@pytest.mark.parametrize( + ( + "mode_options", + "init_input", + "entities_step", + "entities_input", + "extra_submits", + ), + [ + pytest.param( + {}, + {"domains": ["climate"], "include_exclude_mode": "include"}, + "include", + {"entities": ["climate.new"]}, + [{}], + id="bridge", + ), + pytest.param( + {"mode": "accessory"}, + { + "domains": ["climate"], + "include_exclude_mode": "include", + "mode": "accessory", + }, + "accessory", + {"entities": "climate.new"}, + [], + id="accessory", + ), + ], +) +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_options_flow_climate_step_shows_current_accessory( - hass: HomeAssistant, homekit_mode: str + port_mock: MagicMock, + hass: HomeAssistant, + hk_driver: HomeDriver, + mode_options: dict[str, str], + init_input: dict[str, Any], + entities_step: str, + entities_input: dict[str, Any], + extra_submits: list[dict[str, Any]], ) -> None: """Test the climate labels show the accessory the entity uses now.""" - config_entry = _mock_config_entry_with_options_populated() + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + options={ + **mode_options, + "filter": { + "include_domains": [], + "include_entities": ["climate.new"], + "exclude_domains": [], + "exclude_entities": [], + }, + }, + ) config_entry.add_to_hass(hass) + # A basic climate entity bridges as a Thermostat hass.states.async_set("climate.new", "off") await hass.async_block_till_done() - # A loaded entry exposes the bridged accessories through its runtime - # data; accessory mode reads the single accessory from the driver - thermostat = type("Thermostat", (), {"entity_id": "climate.new"})() - if homekit_mode == "bridge": - homekit = Mock(bridge=Mock(accessories={2: thermostat})) - else: - homekit = Mock(bridge=None, driver=Mock(accessory=thermostat)) - config_entry.runtime_data = Mock(homekit=homekit) + with ( + patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver), + patch("pyhap.util.get_local_address", return_value="10.10.10.10"), + ): + hk_driver.async_start = AsyncMock() + hk_driver.async_stop = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "domains": ["climate"], - "include_exclude_mode": "include", - }, - ) - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"entities": ["climate.new"]}, - ) - assert result2["step_id"] == "climate" - assert [str(key) for key in result2["data_schema"].schema] == [ - "new (climate.new) [Thermostat]" - ] + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=init_input + ) + assert result["step_id"] == entities_step + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=entities_input + ) + assert result2["step_id"] == "climate" + assert [str(key) for key in result2["data_schema"].schema] == [ + "new (climate.new) [Thermostat]" + ] - # The annotated label still round trips to the entity id - result2 = await hass.config_entries.options.async_configure( - result2["flow_id"], - user_input={"new (climate.new) [Thermostat]": "heater_cooler"}, - ) - assert result2["step_id"] == "bridged_device_triggers" - with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + # The annotated label still round trips to the entity id result3 = await hass.config_entries.options.async_configure( result2["flow_id"], - user_input={}, + user_input={"new (climate.new) [Thermostat]": "heater_cooler"}, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options["entity_config"]["climate.new"]["type"] == ( - "heater_cooler" - ) + for submit_input in extra_submits: + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], user_input=submit_input + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert config_entry.options["entity_config"]["climate.new"]["type"] == ( + "heater_cooler" + ) + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_flow_climate_step_with_whole_domain_included( From 4f2a7ce6d53cc80be493e3c5259510d2e9f1d8a3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 11 Jul 2026 14:23:24 +0200 Subject: [PATCH 479/707] Fix CI (#176272) --- tests/components/enphase_envoy/snapshots/test_services.ambr | 6 ------ .../scorpiontrack/snapshots/test_device_tracker.ambr | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 tests/components/enphase_envoy/snapshots/test_services.ambr diff --git a/tests/components/enphase_envoy/snapshots/test_services.ambr b/tests/components/enphase_envoy/snapshots/test_services.ambr deleted file mode 100644 index 620d07cc389d..000000000000 --- a/tests/components/enphase_envoy/snapshots/test_services.ambr +++ /dev/null @@ -1,6 +0,0 @@ -# serializer version: 1 -# name: test_has_services - list([ - 'token_lifetime', - ]) -# --- diff --git a/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr index 3f2df867a0ca..d242dc097022 100644 --- a/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr +++ b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : 51.5074, - : -0.1278, + : 51.5074, + : -0.1278, : , : , }), From 4c41f56079f0fe20c6ba03dc8198ffbf0d82261b Mon Sep 17 00:00:00 2001 From: Sarabveer Singh <4297171+sarabveer@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:14:38 -0400 Subject: [PATCH 480/707] Add vehicle current and Wi-Fi RSSI sensors for Tesla Wall Connector (#176243) --- .../components/tesla_wall_connector/const.py | 1 + .../tesla_wall_connector/coordinator.py | 10 ++++- .../components/tesla_wall_connector/sensor.py | 42 ++++++++++++++++++- .../tesla_wall_connector/strings.json | 9 ++++ .../tesla_wall_connector/conftest.py | 36 +++++++++++++--- .../test_binary_sensor.py | 4 ++ .../tesla_wall_connector/test_init.py | 17 ++++++-- .../tesla_wall_connector/test_sensor.py | 11 +++++ 8 files changed, 118 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py index 9a9d31e6f09f..fac6a3d46bb1 100644 --- a/homeassistant/components/tesla_wall_connector/const.py +++ b/homeassistant/components/tesla_wall_connector/const.py @@ -7,6 +7,7 @@ WALLCONNECTOR_SERIAL_NUMBER = "serial_number" WALLCONNECTOR_DATA_VITALS = "vitals" WALLCONNECTOR_DATA_LIFETIME = "lifetime" +WALLCONNECTOR_DATA_WIFI_STATUS = "wifi_status" WALLCONNECTOR_DEVICE_MANUFACTURER = "Tesla" WALLCONNECTOR_DEVICE_MODEL = "Wall Connector" diff --git a/homeassistant/components/tesla_wall_connector/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py index dfc22918abda..9d0080da0824 100644 --- a/homeassistant/components/tesla_wall_connector/coordinator.py +++ b/homeassistant/components/tesla_wall_connector/coordinator.py @@ -1,5 +1,6 @@ """DataUpdateCoordinator for the Tesla Wall Connector integration.""" +import asyncio from dataclasses import dataclass from datetime import timedelta import logging @@ -20,6 +21,7 @@ from .const import ( DEFAULT_SCAN_INTERVAL, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS, + WALLCONNECTOR_DATA_WIFI_STATUS, ) _LOGGER = logging.getLogger(__name__) @@ -66,8 +68,11 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]): async def _async_update_data(self) -> dict: """Fetch new data from the Wall Connector.""" try: - vitals = await self._wall_connector.async_get_vitals() - lifetime = await self._wall_connector.async_get_lifetime() + vitals, lifetime, wifi_status = await asyncio.gather( + self._wall_connector.async_get_vitals(), + self._wall_connector.async_get_lifetime(), + self._wall_connector.async_get_wifi_status(), + ) except WallConnectorConnectionTimeoutError as ex: raise UpdateFailed( f"Could not fetch data from Tesla WallConnector at {self._hostname}:" @@ -87,4 +92,5 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]): return { WALLCONNECTOR_DATA_VITALS: vitals, WALLCONNECTOR_DATA_LIFETIME: lifetime, + WALLCONNECTOR_DATA_WIFI_STATUS: wifi_status, } diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 9105ac493cc4..f76a4088a1a3 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -21,8 +22,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS +from .const import ( + WALLCONNECTOR_DATA_LIFETIME, + WALLCONNECTOR_DATA_VITALS, + WALLCONNECTOR_DATA_WIFI_STATUS, +) from .coordinator import WallConnectorConfigEntry, WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin @@ -48,6 +54,8 @@ class WallConnectorSensorDescription( ): """Sensor entity description with a function pointer for getting sensor value.""" + suggested_object_id: str | None = None + WALL_CONNECTOR_SENSORS = [ WallConnectorSensorDescription( @@ -111,6 +119,15 @@ WALL_CONNECTOR_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + WallConnectorSensorDescription( + key="vehicle_current_a", + translation_key="vehicle_current_a", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].vehicle_current_a, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), WallConnectorSensorDescription( key="current_a_a", translation_key="current_a_a", @@ -185,12 +202,24 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="energy_kWh", + translation_key="energy_kwh", + suggested_object_id="energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + WallConnectorSensorDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + suggested_object_id="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + value_fn=lambda data: data[WALLCONNECTOR_DATA_WIFI_STATUS].wifi_rssi, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), ] @@ -226,7 +255,16 @@ class WallConnectorSensorEntity(WallConnectorEntity, SensorEntity): @property @override - def native_value(self): + def suggested_object_id(self) -> str | None: + """Return suggested object id.""" + if self.entity_description.suggested_object_id is not None: + return self.entity_description.suggested_object_id + + return super().suggested_object_id + + @property + @override + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 0269e85b1123..c21822edc180 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -39,6 +39,9 @@ "current_c_a": { "name": "Phase C current" }, + "energy_kwh": { + "name": "Lifetime energy" + }, "grid_hz": { "name": "Grid frequency" }, @@ -78,6 +81,9 @@ "total_power_w": { "name": "Total power" }, + "vehicle_current_a": { + "name": "Vehicle current" + }, "voltage_a_v": { "name": "Phase A voltage" }, @@ -86,6 +92,9 @@ }, "voltage_c_v": { "name": "Phase C voltage" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" } } } diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index 8d72d14d9720..de3cb20c4392 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -6,7 +6,7 @@ from typing import Any from unittest.mock import MagicMock, patch import pytest -from tesla_wall_connector.wall_connector import Lifetime, Version, Vitals +from tesla_wall_connector.wall_connector import Lifetime, Version, Vitals, WifiStatus from homeassistant.components.tesla_wall_connector.const import ( DEFAULT_SCAN_INTERVAL, @@ -52,7 +52,11 @@ def get_default_version_data(): async def create_wall_connector_entry( - hass: HomeAssistant, side_effect=None, vitals_data=None, lifetime_data=None + hass: HomeAssistant, + side_effect: type[Exception] | Exception | None = None, + vitals_data: Vitals | None = None, + lifetime_data: Lifetime | None = None, + wifi_status_data: WifiStatus | None = None, ) -> MockConfigEntry: """Create a wall connector entry in hass.""" entry = MockConfigEntry( @@ -78,6 +82,11 @@ async def create_wall_connector_entry( return_value=lifetime_data, side_effect=side_effect, ), + patch( + "tesla_wall_connector.WallConnector.async_get_wifi_status", + return_value=wifi_status_data, + side_effect=side_effect, + ), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -100,6 +109,7 @@ def get_vitals_mock() -> Vitals: mock.currentA_a = 10 mock.currentB_a = 11.1 mock.currentC_a = 12 + mock.vehicle_current_a = 32 mock.total_power_w = 7650.3 mock.session_energy_wh = 1234.56 mock.contactor_closed = False @@ -112,6 +122,13 @@ def get_lifetime_mock() -> Lifetime: return MagicMock(auto_spec=Lifetime) +def get_wifi_status_mock() -> WifiStatus: + """Get mocked Wi-Fi status object.""" + mock = MagicMock(auto_spec=WifiStatus) + mock.wifi_rssi = -42 + return mock + + @dataclass class EntityAndExpectedValues: """Class for keeping entity id along with expected update values.""" @@ -123,17 +140,22 @@ class EntityAndExpectedValues: async def _test_sensors( hass: HomeAssistant, - entities_and_expected_values, + entities_and_expected_values: list[EntityAndExpectedValues], vitals_first_update: Vitals, vitals_second_update: Vitals, lifetime_first_update: Lifetime, lifetime_second_update: Lifetime, + wifi_status_first_update: WifiStatus, + wifi_status_second_update: WifiStatus, ) -> None: """Test update of sensor values.""" # First Update: Data is fetched when the integration is initialized await create_wall_connector_entry( - hass, vitals_data=vitals_first_update, lifetime_data=lifetime_first_update + hass, + vitals_data=vitals_first_update, + lifetime_data=lifetime_first_update, + wifi_status_data=wifi_status_first_update, ) # Verify expected vs actual values of first update @@ -155,11 +177,15 @@ async def _test_sensors( "tesla_wall_connector.WallConnector.async_get_lifetime", return_value=lifetime_second_update, ), + patch( + "tesla_wall_connector.WallConnector.async_get_wifi_status", + return_value=wifi_status_second_update, + ), ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL) ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify expected vs actual values of second update for entity in entities_and_expected_values: diff --git a/tests/components/tesla_wall_connector/test_binary_sensor.py b/tests/components/tesla_wall_connector/test_binary_sensor.py index 3990369262d3..050f5c152813 100644 --- a/tests/components/tesla_wall_connector/test_binary_sensor.py +++ b/tests/components/tesla_wall_connector/test_binary_sensor.py @@ -7,6 +7,7 @@ from .conftest import ( _test_sensors, get_lifetime_mock, get_vitals_mock, + get_wifi_status_mock, ) @@ -29,6 +30,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_second_update.vehicle_connected = False lifetime_mock = get_lifetime_mock() + wifi_status_mock = get_wifi_status_mock() await _test_sensors( hass, @@ -37,4 +39,6 @@ async def test_sensors(hass: HomeAssistant) -> None: vitals_second_update=mock_vitals_second_update, lifetime_first_update=lifetime_mock, lifetime_second_update=lifetime_mock, + wifi_status_first_update=wifi_status_mock, + wifi_status_second_update=wifi_status_mock, ) diff --git a/tests/components/tesla_wall_connector/test_init.py b/tests/components/tesla_wall_connector/test_init.py index 0393bf372473..04ef327c354c 100644 --- a/tests/components/tesla_wall_connector/test_init.py +++ b/tests/components/tesla_wall_connector/test_init.py @@ -11,7 +11,12 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import create_wall_connector_entry, get_lifetime_mock, get_vitals_mock +from .conftest import ( + create_wall_connector_entry, + get_lifetime_mock, + get_vitals_mock, + get_wifi_status_mock, +) async def test_init_success( @@ -20,7 +25,10 @@ async def test_init_success( """Test setup and that we get the device info, including firmware version.""" entry = await create_wall_connector_entry( - hass, vitals_data=get_vitals_mock(), lifetime_data=get_lifetime_mock() + hass, + vitals_data=get_vitals_mock(), + lifetime_data=get_lifetime_mock(), + wifi_status_data=get_wifi_status_mock(), ) assert entry.state is ConfigEntryState.LOADED @@ -46,7 +54,10 @@ async def test_load_unload(hass: HomeAssistant) -> None: """Config entry can be unloaded.""" entry = await create_wall_connector_entry( - hass, vitals_data=get_vitals_mock(), lifetime_data=get_lifetime_mock() + hass, + vitals_data=get_vitals_mock(), + lifetime_data=get_lifetime_mock(), + wifi_status_data=get_wifi_status_mock(), ) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 07746beb7ad7..261f92ffab40 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -7,6 +7,7 @@ from .conftest import ( _test_sensors, get_lifetime_mock, get_vitals_mock, + get_wifi_status_mock, ) @@ -35,6 +36,9 @@ async def test_sensors(hass: HomeAssistant) -> None: EntityAndExpectedValues( "sensor.tesla_wall_connector_energy", "988.022", "989.0" ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_vehicle_current", "32", "16" + ), EntityAndExpectedValues( "sensor.tesla_wall_connector_phase_a_current", "10", "7" ), @@ -59,6 +63,7 @@ async def test_sensors(hass: HomeAssistant) -> None: EntityAndExpectedValues( "sensor.tesla_wall_connector_session_energy", "1.23456", "0.1122" ), + EntityAndExpectedValues("sensor.tesla_wall_connector_wifi_rssi", "-42", "-54"), ] mock_vitals_first_update = get_vitals_mock() @@ -76,6 +81,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_second_update.currentA_a = 7 mock_vitals_second_update.currentB_a = 8 mock_vitals_second_update.currentC_a = 9 + mock_vitals_second_update.vehicle_current_a = 16 mock_vitals_second_update.total_power_w = 5499.5 mock_vitals_second_update.session_energy_wh = 112.2 @@ -83,6 +89,9 @@ async def test_sensors(hass: HomeAssistant) -> None: lifetime_mock_first_update.energy_wh = 988022 lifetime_mock_second_update = get_lifetime_mock() lifetime_mock_second_update.energy_wh = 989000 + wifi_status_first_update = get_wifi_status_mock() + wifi_status_second_update = get_wifi_status_mock() + wifi_status_second_update.wifi_rssi = -54 await _test_sensors( hass, @@ -91,4 +100,6 @@ async def test_sensors(hass: HomeAssistant) -> None: vitals_second_update=mock_vitals_second_update, lifetime_first_update=lifetime_mock_first_update, lifetime_second_update=lifetime_mock_second_update, + wifi_status_first_update=wifi_status_first_update, + wifi_status_second_update=wifi_status_second_update, ) From a364df676c202901b37fcade9a163528c038a1e8 Mon Sep 17 00:00:00 2001 From: Maximilian <43999966+DeerMaximum@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:57:41 +0200 Subject: [PATCH 481/707] Change service texts to third-person singular for NINA (#176280) --- homeassistant/components/nina/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index ad7caa06570f..ea885c6c1b00 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -132,7 +132,7 @@ }, "services": { "get_details": { - "description": "Get the details of a warning.", + "description": "Retrieves the details of a warning.", "name": "Get warning details" } } From 39fc1b8e6bf2ec57651e077775e306c1fa088bea Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 11 Jul 2026 20:58:05 +0200 Subject: [PATCH 482/707] Only create Overkiz number entities for supported commands (#176279) --- homeassistant/components/overkiz/number.py | 3 + .../overkiz/snapshots/test_number.ambr | 428 ------------------ 2 files changed, 3 insertions(+), 428 deletions(-) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index b20637b95d00..8307b1e8f13d 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -211,6 +211,9 @@ async def async_setup_entry( if not (description := SUPPORTED_STATES.get(state)): continue + if not device.supports_command(description.command): + continue + # Mirror the cover's position inversion. if description.key == OverkizState.CORE_MEMORIZED_1_POSITION and ( cover_description := ( diff --git a/tests/components/overkiz/snapshots/test_number.ambr b/tests/components/overkiz/snapshots/test_number.ambr index 80410a24e3d3..60b109664adb 100644 --- a/tests/components/overkiz/snapshots/test_number.ambr +++ b/tests/components/overkiz/snapshots/test_number.ambr @@ -183,68 +183,6 @@ 'state': '4', }) # --- -# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_freeze_protection_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 15, - : 5, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.my_home_patio_water_heating_freeze_protection_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Freeze protection temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sun-thermometer-outline', - 'original_name': 'Freeze protection temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-5643/109286#1-core:SecuredPositionTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_freeze_protection_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Patio Water Heating Freeze protection temperature', - : 'mdi:sun-thermometer-outline', - : 15, - : 5, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.my_home_patio_water_heating_freeze_protection_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_target_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -365,68 +303,6 @@ 'state': '60.0', }) # --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_garden_radiator_comfort_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 30, - : 7, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_garden_radiator_comfort_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Comfort room temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:home-thermometer-outline', - 'original_name': 'Comfort room temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/15702199#1-core:ComfortRoomTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_garden_radiator_comfort_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Garden Radiator Comfort room temperature', - : 'mdi:home-thermometer-outline', - : 30, - : 7, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.maple_residence_garden_radiator_comfort_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', - }) -# --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_hallway_shutter_my_position-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -487,248 +363,6 @@ 'state': '15', }) # --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_inlet_my_position-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 100, - : 0, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_air_inlet_my_position', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'My position', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:content-save-cog', - 'original_name': 'My position', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/10001-core:Memorized1PositionState', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_inlet_my_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Living Room Air Inlet My position', - : 'mdi:content-save-cog', - : 100, - : 0, - : , - : 1.0, - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_air_inlet_my_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_outlet_my_position-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 100, - : 0, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_air_outlet_my_position', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'My position', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:content-save-cog', - 'original_name': 'My position', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/10002-core:Memorized1PositionState', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_outlet_my_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Living Room Air Outlet My position', - : 'mdi:content-save-cog', - : 100, - : 0, - : , - : 1.0, - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_air_outlet_my_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_transfer_my_position-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 100, - : 0, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_air_transfer_my_position', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'My position', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:content-save-cog', - 'original_name': 'My position', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/10003-core:Memorized1PositionState', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_transfer_my_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Living Room Air Transfer My position', - : 'mdi:content-save-cog', - : 100, - : 0, - : , - : 1.0, - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_air_transfer_my_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_radiator_comfort_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 30, - : 7, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_radiator_comfort_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Comfort room temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:home-thermometer-outline', - 'original_name': 'Comfort room temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/9253412#1-core:ComfortRoomTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_radiator_comfort_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Living Room Radiator Comfort room temperature', - : 'mdi:home-thermometer-outline', - : 30, - : 7, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_radiator_comfort_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', - }) -# --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_nursery_shutter_my_position-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -849,68 +483,6 @@ 'state': '15', }) # --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_study_radiator_comfort_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 30, - : 7, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_study_radiator_comfort_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Comfort room temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:home-thermometer-outline', - 'original_name': 'Comfort room temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/9187218#1-core:ComfortRoomTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_study_radiator_comfort_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Study Radiator Comfort room temperature', - : 'mdi:home-thermometer-outline', - : 30, - : 7, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.maple_residence_study_radiator_comfort_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', - }) -# --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_terrace_radiator_comfort_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From ca97f5a7bc64da33aecdd6447614b342ff82e1a1 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 11 Jul 2026 20:58:47 +0200 Subject: [PATCH 483/707] Move units_id to common in MELCloud Home (#176261) --- homeassistant/components/melcloud_home/common.py | 7 +++++++ homeassistant/components/melcloud_home/entity.py | 7 ------- homeassistant/components/melcloud_home/number.py | 4 ++-- homeassistant/components/melcloud_home/switch.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/melcloud_home/common.py b/homeassistant/components/melcloud_home/common.py index 84cb3bb3f8c2..d3e1417018a7 100644 --- a/homeassistant/components/melcloud_home/common.py +++ b/homeassistant/components/melcloud_home/common.py @@ -31,3 +31,10 @@ def async_setup_unit_entities( _async_add_new_ata_units(list(coordinator.ata_units.values())) _async_add_new_atw_units(list(coordinator.atw_units.values())) + + +def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]: + """Return the client keyword argument selecting this unit.""" + if isinstance(unit, ATAUnit): + return {"ata_unit_ids": [unit.id]} + return {"atw_unit_ids": [unit.id]} diff --git a/homeassistant/components/melcloud_home/entity.py b/homeassistant/components/melcloud_home/entity.py index 79a797fe7335..da84d8e9abbe 100644 --- a/homeassistant/components/melcloud_home/entity.py +++ b/homeassistant/components/melcloud_home/entity.py @@ -13,13 +13,6 @@ from .const import DEVICE_ATA, DEVICE_ATW, DOMAIN, WEB_BASE_URL from .coordinator import MelCloudHomeCoordinator -def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]: - """Return the client keyword argument selecting this unit.""" - if isinstance(unit, ATAUnit): - return {"ata_unit_ids": [unit.id]} - return {"atw_unit_ids": [unit.id]} - - class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]): """Base entity for MELCloud Home.""" diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py index fcc42867328f..7cb18e6f0455 100644 --- a/homeassistant/components/melcloud_home/number.py +++ b/homeassistant/components/melcloud_home/number.py @@ -21,10 +21,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities +from .common import async_setup_unit_entities, unit_ids from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator -from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity, unit_ids +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py index 1d0d8062f526..67d130d77947 100644 --- a/homeassistant/components/melcloud_home/switch.py +++ b/homeassistant/components/melcloud_home/switch.py @@ -21,10 +21,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities +from .common import async_setup_unit_entities, unit_ids from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator -from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity, unit_ids +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity PARALLEL_UPDATES = 1 From 0b0465073ce518c44ba400dbd15ab6acfb188812 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:00:06 +0200 Subject: [PATCH 484/707] Bump uiprotect to 15.9.0 (#176266) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 7109260ffc32..ecb6f202d85d 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.7.1"] + "requirements": ["uiprotect==15.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 18614c26ef08..1531a8ff4a51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3255,7 +3255,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.7.1 +uiprotect==15.9.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From 336d10182d6a761c98911b2b22c9e353045301dc Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 12 Jul 2026 05:01:39 +1000 Subject: [PATCH 485/707] Bump tesla-fleet-api to 1.6.3 (#176271) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index a4b7369ef7e0..b3831daecbb3 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.6.0"] + "requirements": ["tesla-fleet-api==1.6.3"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 8179abd7be77..6579ef22abfa 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.6.0", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.6.3", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 026dd8b49dd7..66b5bf3d71f8 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.6.0"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1531a8ff4a51..4f8ad1c8c01c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3165,7 +3165,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.6.0 +tesla-fleet-api==1.6.3 # homeassistant.components.powerwall tesla-powerwall==0.5.3 From 4faf95d838a2d7af85daf1556be85e33e071924f Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:14:52 +0200 Subject: [PATCH 486/707] Bump guntamatic to v1.9.1 (#176301) --- homeassistant/components/guntamatic/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 1b062a9a073a..e6ce097ba43d 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.9.0"] + "requirements": ["guntamatic==1.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f8ad1c8c01c..9f4a8af93d63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ growattServer==2.1.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.9.0 +guntamatic==1.9.1 # homeassistant.components.profiler guppy3==3.1.7 From 67b1d02e312e5ca49a906c4e26920792c68a3971 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 11 Jul 2026 22:35:31 -0400 Subject: [PATCH 487/707] Bump python-roborock to 5.29.0 (#176311) --- homeassistant/components/roborock/manifest.json | 2 +- homeassistant/components/roborock/sensor.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index be94876e0dd8..e16b446f3f35 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.26.0", + "python-roborock==5.29.0", "vacuum-map-parser-roborock==0.1.5" ] } diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 86f113bf40ef..7cb45220f160 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -96,7 +96,7 @@ class RoborockSensorDescriptionQ10(SensorEntityDescription): def _dock_error_value_fn(state: DeviceState) -> str | None: if ( status := state.status.dock_error_status - ) is not None and state.status.dock_type != RoborockDockTypeCode.no_dock: + ) is not None and state.status.dock_type != RoborockDockTypeCode.o0_dock: return status.name return None diff --git a/requirements_all.txt b/requirements_all.txt index 9f4a8af93d63..f6a2f25560f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2743,7 +2743,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==5.26.0 +python-roborock==5.29.0 # homeassistant.components.smarttub python-smarttub==0.0.47 From 3c930b9cd5d3081c840f652752914f2dbbb3e150 Mon Sep 17 00:00:00 2001 From: Pieter Smit Date: Sun, 12 Jul 2026 09:53:19 +0200 Subject: [PATCH 488/707] Add estimated arrival sensor to Picnic (#176295) --- homeassistant/components/picnic/const.py | 1 + homeassistant/components/picnic/coordinator.py | 5 +++++ homeassistant/components/picnic/icons.json | 3 +++ homeassistant/components/picnic/sensor.py | 12 ++++++++++++ homeassistant/components/picnic/strings.json | 3 +++ tests/components/picnic/test_sensor.py | 17 ++++++++++++++++- 6 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 0c7336263d34..b913092771fe 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -32,5 +32,6 @@ SENSOR_LAST_ORDER_DELIVERY_TIME = "last_order_delivery_time" SENSOR_LAST_ORDER_TOTAL_PRICE = "last_order_total_price" SENSOR_NEXT_DELIVERY_ETA_START = "next_delivery_eta_start" SENSOR_NEXT_DELIVERY_ETA_END = "next_delivery_eta_end" +SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL = "next_delivery_estimated_arrival" SENSOR_NEXT_DELIVERY_SLOT_START = "next_delivery_slot_start" SENSOR_NEXT_DELIVERY_SLOT_END = "next_delivery_slot_end" diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 0a731c51b3fd..43aca27b3bf2 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -153,6 +153,11 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): if "eta2" in next_delivery: del next_delivery["eta2"] + # The position response's eta (unix timestamp in milliseconds) feeds + # the estimated arrival sensor; the API only serves it shortly before + # the delivery, so that sensor is unknown outside that window + next_delivery["estimated_arrival"] = delivery_position.get("eta") + # Determine the total price by adding up the total price of all sub-orders total_price = 0 for order in last_order.get("orders", []): diff --git a/homeassistant/components/picnic/icons.json b/homeassistant/components/picnic/icons.json index 9a28ea8e55f9..86b612ff97cf 100644 --- a/homeassistant/components/picnic/icons.json +++ b/homeassistant/components/picnic/icons.json @@ -25,6 +25,9 @@ "last_order_total_price": { "default": "mdi:cash-marker" }, + "next_delivery_estimated_arrival": { + "default": "mdi:clock-fast" + }, "next_delivery_eta_end": { "default": "mdi:clock-end" }, diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index b9a2c152cab7..0deb382a31cc 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -29,6 +29,7 @@ from .const import ( SENSOR_LAST_ORDER_SLOT_START, SENSOR_LAST_ORDER_STATUS, SENSOR_LAST_ORDER_TOTAL_PRICE, + SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL, SENSOR_NEXT_DELIVERY_ETA_END, SENSOR_NEXT_DELIVERY_ETA_START, SENSOR_NEXT_DELIVERY_SLOT_END, @@ -166,6 +167,17 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( str(next_delivery.get("eta", {}).get("end")) ), ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL, + translation_key=SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL, + device_class=SensorDeviceClass.TIMESTAMP, + data_type="next_delivery_data", + value_fn=lambda next_delivery: ( + dt_util.utc_from_timestamp(next_delivery["estimated_arrival"] / 1000) + if next_delivery.get("estimated_arrival") + else None + ), + ), PicnicSensorEntityDescription( key=SENSOR_NEXT_DELIVERY_SLOT_START, translation_key=SENSOR_NEXT_DELIVERY_SLOT_START, diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index e2cea9b4d4d2..8f597574362c 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -61,6 +61,9 @@ "last_order_total_price": { "name": "Total price of last order" }, + "next_delivery_estimated_arrival": { + "name": "Estimated arrival of next delivery" + }, "next_delivery_eta_end": { "name": "Expected end of next delivery" }, diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 718f1ef292d6..a3d8a8281ec1 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -409,7 +409,8 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): "eta_window": { "start": "2021-03-05T10:19:20.452+00:00", "end": "2021-03-05T10:39:20.452+00:00", - } + }, + "eta": 1614941090000, } await self._coordinator.async_refresh() @@ -425,6 +426,20 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): "sensor.mock_title_expected_end_of_next_delivery", "2021-03-05T10:39:20+00:00", ) + self._assert_sensor( + "sensor.mock_title_estimated_arrival_of_next_delivery", + "2021-03-05T10:44:50+00:00", + ) + + # The live estimate is cleared again once position data disappears + self.picnic_mock().get_delivery_position.return_value = {} + async_fire_time_changed(self.hass, dt_util.utcnow() + timedelta(minutes=31)) + await self.hass.async_block_till_done(wait_background_tasks=True) + + self._assert_sensor( + "sensor.mock_title_estimated_arrival_of_next_delivery", + STATE_UNKNOWN, + ) async def test_sensors_no_data(self): """Test sensor states when the api only returns empty objects.""" From 23ab6efce288396320368c4d680c431349358013 Mon Sep 17 00:00:00 2001 From: epinethrone <172391900+epinethrone@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:05:33 +0200 Subject: [PATCH 489/707] Bump ha-philipsjs to 3.2.5 (#176303) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/philips_js/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index e80a925094ce..0d5bfe995397 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.2.4"], + "requirements": ["ha-philipsjs==3.2.5"], "zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f6a2f25560f1..3642e44c6bc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1204,7 +1204,7 @@ ha-ffmpeg==3.2.2 ha-iotawattpy==0.2.1 # homeassistant.components.philips_js -ha-philipsjs==3.2.4 +ha-philipsjs==3.2.5 # homeassistant.components.homeassistant_hardware ha-silabs-firmware-client==0.3.0 From 0cf4a8240d99027b7154a9825a097433e8be670e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Jul 2026 13:17:58 +0200 Subject: [PATCH 490/707] Extract entities from zone condition options (#175421) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- homeassistant/helpers/condition.py | 15 ++++++++++ tests/helpers/test_condition.py | 45 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 971cb9a961e7..4896cf59b498 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -48,6 +48,7 @@ from homeassistant.const import ( CONF_TARGET, CONF_VALUE_TEMPLATE, CONF_WEEKDAY, + CONF_ZONE, ENTITY_MATCH_ALL, ENTITY_MATCH_ANY, STATE_UNAVAILABLE, @@ -2148,6 +2149,20 @@ def async_extract_entities(config: ConfigType | Template) -> set[str]: referenced.add(value) continue + if condition == "zone": + options = config.get(CONF_OPTIONS, {}) + referenced.update(options.get(CONF_ENTITY_ID, [])) + referenced.update(options.get(CONF_ZONE, [])) + + elif condition in ( + "zone.in_zone", + "zone.not_in_zone", + "zone.occupancy_is_detected", + "zone.occupancy_is_not_detected", + ): + if zone_entity_id := config.get(CONF_OPTIONS, {}).get(CONF_ZONE): + referenced.add(zone_entity_id) + entity_ids = config.get(CONF_ENTITY_ID) if isinstance(entity_ids, str): diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index a98c47c30846..449864e0bf60 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2134,6 +2134,25 @@ async def test_extract_entities(hass: HomeAssistant) -> None: "entity_id": ["sensor.temperature_9", "sensor.temperature_10"], "below": 110, }, + { + "condition": "zone", + "options": { + "entity_id": [ + "device_tracker.paulus", + "device_tracker.anne_therese", + ], + "zone": ["zone.home"], + }, + }, + { + "condition": "zone.in_zone", + "target": {"entity_id": "person.paulus"}, + "options": {"zone": "zone.work", "behavior": "any"}, + }, + { + "condition": "zone.occupancy_is_detected", + "options": {"zone": "zone.school"}, + }, { "condition": "time", "after": "input_datetime.start", @@ -2147,7 +2166,10 @@ async def test_extract_entities(hass: HomeAssistant) -> None: ], } ) == { + "device_tracker.anne_therese", + "device_tracker.paulus", "input_datetime.start", + "person.paulus", "sensor.end", "sensor.temperature", "sensor.temperature_2", @@ -2159,6 +2181,29 @@ async def test_extract_entities(hass: HomeAssistant) -> None: "sensor.temperature_8", "sensor.temperature_9", "sensor.temperature_10", + "zone.home", + "zone.school", + "zone.work", + } + + +async def test_extract_entities_zone_condition_validated(hass: HomeAssistant) -> None: + """Test extracting entities from a validated legacy zone condition. + + Validation moves the top level entity_id and zone fields into options. + """ + assert await async_setup_component(hass, "zone", {}) + config = await condition.async_validate_condition_config( + hass, + { + "condition": "zone", + "entity_id": "device_tracker.paulus", + "zone": "zone.home", + }, + ) + assert condition.async_extract_entities(config) == { + "device_tracker.paulus", + "zone.home", } From 0b67884b88d7d91cedcd02342f756dc832c75035 Mon Sep 17 00:00:00 2001 From: guclumhg Date: Sun, 12 Jul 2026 14:18:56 +0300 Subject: [PATCH 491/707] Set PARALLEL_UPDATES in analytics_insights (#176321) --- homeassistant/components/analytics_insights/quality_scale.yaml | 2 +- homeassistant/components/analytics_insights/sensor.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/analytics_insights/quality_scale.yaml b/homeassistant/components/analytics_insights/quality_scale.yaml index e842dc6f3b1f..3455dc26750b 100644 --- a/homeassistant/components/analytics_insights/quality_scale.yaml +++ b/homeassistant/components/analytics_insights/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: done comment: | The coordinator handles this. - parallel-updates: todo + parallel-updates: done reauthentication-flow: status: exempt comment: | diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index 05136dc90611..f68a697decae 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -20,6 +20,8 @@ from . import AnalyticsInsightsConfigEntry from .const import DOMAIN from .coordinator import AnalyticsData, HomeassistantAnalyticsDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AnalyticsSensorEntityDescription(SensorEntityDescription): From aa75e29bc7fe057f5c293ad802cf92edd1b3c3c2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 12 Jul 2026 21:20:51 +1000 Subject: [PATCH 492/707] Bump tesla-fleet-api to 1.7.1 (#176317) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index b3831daecbb3..8929dc0be85b 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.6.3"] + "requirements": ["tesla-fleet-api==1.7.1"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 6579ef22abfa..e8ac86c3076d 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.6.3", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.7.1", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 66b5bf3d71f8..0a37f0856c2e 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.6.3"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3642e44c6bc1..dc857d7562a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3165,7 +3165,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.6.3 +tesla-fleet-api==1.7.1 # homeassistant.components.powerwall tesla-powerwall==0.5.3 From 7830e2c0525dcc2db4efa51defa152959518effa Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:22:01 +0200 Subject: [PATCH 493/707] Bump music-assistant-client to v1.4.3 (#176318) --- homeassistant/components/music_assistant/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 121273348a5b..9baa8ea1735f 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.3.6"], + "requirements": ["music-assistant-client==1.4.3"], "zeroconf": ["_mass._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index dc857d7562a3..be5c56e0fa60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1625,7 +1625,7 @@ mozart-api==6.2.0.44.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.6 +music-assistant-client==1.4.3 # homeassistant.components.tts mutagen==1.48.1 From 1e63ba5bd4ff66ab8ddfb68c79cbb60809407a0e Mon Sep 17 00:00:00 2001 From: Isak Nyberg <36712644+IsakNyberg@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:36:27 +0200 Subject: [PATCH 494/707] remove permobil (#169933) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 2 - homeassistant/components/permobil/__init__.py | 74 ++-- .../components/permobil/binary_sensor.py | 70 ---- .../components/permobil/config_flow.py | 181 +-------- homeassistant/components/permobil/const.py | 11 - .../components/permobil/coordinator.py | 66 ---- homeassistant/components/permobil/entity.py | 29 -- homeassistant/components/permobil/icons.json | 36 -- .../components/permobil/manifest.json | 6 +- homeassistant/components/permobil/sensor.py | 222 ----------- .../components/permobil/strings.json | 81 +--- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 3 - script/hassfest/quality_scale.py | 1 - script/licenses.py | 3 - .../fixtures/current_data.json | 1 - tests/components/permobil/__init__.py | 2 +- tests/components/permobil/conftest.py | 28 -- tests/components/permobil/const.py | 5 - tests/components/permobil/test_config_flow.py | 364 ------------------ tests/components/permobil/test_init.py | 79 ++++ 22 files changed, 118 insertions(+), 1149 deletions(-) delete mode 100644 homeassistant/components/permobil/binary_sensor.py delete mode 100644 homeassistant/components/permobil/const.py delete mode 100644 homeassistant/components/permobil/coordinator.py delete mode 100644 homeassistant/components/permobil/entity.py delete mode 100644 homeassistant/components/permobil/icons.json delete mode 100644 homeassistant/components/permobil/sensor.py delete mode 100644 tests/components/permobil/conftest.py delete mode 100644 tests/components/permobil/const.py delete mode 100644 tests/components/permobil/test_config_flow.py create mode 100644 tests/components/permobil/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 6c01218ecb1a..e57a00aa5e55 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1373,8 +1373,6 @@ CLAUDE.md @home-assistant/core /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 -/homeassistant/components/permobil/ @IsakNyberg -/tests/components/permobil/ @IsakNyberg /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/pglab/ @pglab-electronics diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index ff3127d75a8e..898f43277400 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -1,59 +1,37 @@ """The MyPermobil integration.""" -import logging - -from mypermobil import MyPermobil, MyPermobilClientException - -from homeassistant.const import ( - CONF_CODE, - CONF_EMAIL, - CONF_REGION, - CONF_TOKEN, - CONF_TTL, - Platform, -) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import issue_registry as ir -from .const import APPLICATION -from .coordinator import MyPermobilCoordinator, PermobilConfigEntry - -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "permobil" -async def async_setup_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: - """Set up MyPermobil from a config entry.""" - - # create the API object from the config and save it in hass - session = async_get_clientsession(hass) - p_api = MyPermobil( - application=APPLICATION, - session=session, - email=entry.data[CONF_EMAIL], - region=entry.data[CONF_REGION], - code=entry.data[CONF_CODE], - token=entry.data[CONF_TOKEN], - expiration_date=entry.data[CONF_TTL], +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/permobil", + }, ) - try: - p_api.self_authenticate() - except MyPermobilClientException as err: - _LOGGER.error("Error authenticating %s", err) - raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err - - # create the coordinator with the API object - coordinator = MyPermobilCoordinator(hass, entry, p_api) - await coordinator.async_config_entry_first_refresh() - - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True -async def async_unload_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py deleted file mode 100644 index 2f9f04251047..000000000000 --- a/homeassistant/components/permobil/binary_sensor.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Platform for binary sensor integration.""" - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, override - -from mypermobil import BATTERY_CHARGING - -from homeassistant.components.binary_sensor import ( - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import PermobilConfigEntry -from .entity import PermobilEntity - - -@dataclass(frozen=True, kw_only=True) -class PermobilBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes Permobil binary sensor entity.""" - - is_on_fn: Callable[[Any], bool] - available_fn: Callable[[Any], bool] - - -BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] = ( - PermobilBinarySensorEntityDescription( - is_on_fn=lambda data: data.battery[BATTERY_CHARGING[0]], - available_fn=lambda data: BATTERY_CHARGING[0] in data.battery, - key="is_charging", - translation_key="is_charging", - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: PermobilConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Create and setup the binary sensor.""" - - coordinator = config_entry.runtime_data - - async_add_entities( - PermobilbinarySensor(coordinator=coordinator, description=description) - for description in BINARY_SENSOR_DESCRIPTIONS - ) - - -class PermobilbinarySensor(PermobilEntity, BinarySensorEntity): - """Representation of a Binary Sensor.""" - - entity_description: PermobilBinarySensorEntityDescription - - @property - @override - def is_on(self) -> bool: - """Return True if the wheelchair is charging.""" - return self.entity_description.is_on_fn(self.coordinator.data) - - @property - @override - def available(self) -> bool: - """Return True if the sensor has value.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 5b05a8eabbe2..b1711b894697 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -1,184 +1,11 @@ -"""Config flow for MyPermobil integration.""" +"""Config flow to configure Permobil integration.""" -from collections.abc import Mapping -import logging -from typing import Any, override +from homeassistant.config_entries import ConfigFlow -from mypermobil import ( - MyPermobil, - MyPermobilAPIException, - MyPermobilClientException, - MyPermobilEulaException, -) -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL -from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import ( - TextSelector, - TextSelectorConfig, - TextSelectorType, -) - -from .const import APPLICATION, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -GET_EMAIL_SCHEMA = vol.Schema( - { - vol.Required(CONF_EMAIL): TextSelector( - TextSelectorConfig(type=TextSelectorType.EMAIL) - ), - } -) - -GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string}) +from . import DOMAIN class PermobilConfigFlow(ConfigFlow, domain=DOMAIN): - """Permobil config flow.""" + """Permobil integration config flow.""" VERSION = 1 - region_names: dict[str, str] = {} - data: dict[str, str] = {} - - def __init__(self) -> None: - """Initialize flow.""" - hass: HomeAssistant = async_get_hass() - session = async_get_clientsession(hass) - self.p_api = MyPermobil(APPLICATION, session=session) - - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Invoke when a user initiates a flow via the user interface.""" - errors: dict[str, str] = {} - - if user_input: - try: - self.p_api.set_email(user_input[CONF_EMAIL]) - except MyPermobilClientException: - _LOGGER.exception("Error validating email") - errors["base"] = "invalid_email" - - self.data.update(user_input) - - await self.async_set_unique_id(self.data[CONF_EMAIL]) - self._abort_if_unique_id_configured() - - if errors or not user_input: - return self.async_show_form( - step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors - ) - return await self.async_step_region() - - async def async_step_region( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Invoke when a user initiates a flow via the user interface.""" - errors: dict[str, str] = {} - if not user_input: - # fetch the list of regions names and urls from the api - # for the user to select from. - try: - self.region_names = await self.p_api.request_region_names() - _LOGGER.debug( - "region names %s", - ",".join(list(self.region_names.keys())), - ) - except MyPermobilAPIException: - _LOGGER.exception("Error requesting regions") - errors["base"] = "region_fetch_error" - - else: - region_url = self.region_names[user_input[CONF_REGION]] - - self.data[CONF_REGION] = region_url - self.p_api.set_region(region_url) - _LOGGER.debug("region %s", self.p_api.region) - try: - # tell backend to send code to the users email - await self.p_api.request_application_code() - except MyPermobilAPIException: - _LOGGER.exception("Error requesting code") - errors["base"] = "code_request_error" - - if errors or not user_input: - # the error could either be that the fetch region did not pass - # or that the request application code failed - schema = vol.Schema( - { - vol.Required(CONF_REGION): selector.SelectSelector( - selector.SelectSelectorConfig( - options=list(self.region_names.keys()), - mode=selector.SelectSelectorMode.DROPDOWN, - ) - ), - } - ) - return self.async_show_form( - step_id="region", data_schema=schema, errors=errors - ) - - return await self.async_step_email_code() - - async def async_step_email_code( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Second step in config flow to enter the email code.""" - errors: dict[str, str] = {} - - if user_input: - try: - self.p_api.set_code(user_input[CONF_CODE]) - self.data.update(user_input) - token, ttl = await self.p_api.request_application_token() - self.data[CONF_TOKEN] = token - self.data[CONF_TTL] = ttl - except MyPermobilAPIException, MyPermobilClientException: - # the code did not pass validation by the api client - # or the backend returned an error when trying to validate the code - _LOGGER.exception("Error verifying code") - errors["base"] = "invalid_code" - except MyPermobilEulaException: - # The user has not accepted the EULA - errors["base"] = "unsigned_eula" - - if errors or not user_input: - return self.async_show_form( - step_id="email_code", - data_schema=GET_TOKEN_SCHEMA, - errors=errors, - description_placeholders={"app_name": "MyPermobil"}, - ) - - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), title=self.data[CONF_EMAIL], data=self.data - ) - - return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - try: - email: str = entry_data[CONF_EMAIL] - region: str = entry_data[CONF_REGION] - self.p_api.set_email(email) - self.p_api.set_region(region) - self.data = { - CONF_EMAIL: email, - CONF_REGION: region, - } - await self.p_api.request_application_code() - except MyPermobilAPIException: - _LOGGER.exception("Error requesting code for reauth") - return self.async_abort(reason="unknown") - - return await self.async_step_email_code() diff --git a/homeassistant/components/permobil/const.py b/homeassistant/components/permobil/const.py deleted file mode 100644 index fd5fe673f2a7..000000000000 --- a/homeassistant/components/permobil/const.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Constants for the MyPermobil integration.""" - -DOMAIN = "permobil" - -APPLICATION = "Home Assistant" - - -BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge -REGIONS = "regions" -KM = "kilometers" -MILES = "miles" diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py deleted file mode 100644 index 13273949e964..000000000000 --- a/homeassistant/components/permobil/coordinator.py +++ /dev/null @@ -1,66 +0,0 @@ -"""DataUpdateCoordinator for permobil integration.""" - -import asyncio -from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import override - -from mypermobil import MyPermobil, MyPermobilAPIException - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -_LOGGER = logging.getLogger(__name__) - -type PermobilConfigEntry = ConfigEntry[MyPermobilCoordinator] - - -@dataclass -class MyPermobilData: - """MyPermobil data stored in the DataUpdateCoordinator.""" - - battery: dict[str, str | float | int | bool | list | dict] - daily_usage: dict[str, str | float | int | list | dict] - records: dict[str, str | float | int | list | dict] - - -class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): - """MyPermobil coordinator.""" - - config_entry: PermobilConfigEntry - - def __init__( - self, hass: HomeAssistant, config_entry: PermobilConfigEntry, p_api: MyPermobil - ) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="permobil", - update_interval=timedelta(minutes=5), - ) - self.p_api = p_api - - @override - async def _async_update_data(self) -> MyPermobilData: - """Fetch data from the 3 API endpoints.""" - try: - async with asyncio.timeout(10): - battery = await self.p_api.get_battery_info() - daily_usage = await self.p_api.get_daily_usage() - records = await self.p_api.get_usage_records() - return MyPermobilData( - battery=battery, - daily_usage=daily_usage, - records=records, - ) - - except MyPermobilAPIException as err: - _LOGGER.exception( - "Error fetching data from MyPermobil API for account %s", - self.p_api.email, - ) - raise UpdateFailed from err diff --git a/homeassistant/components/permobil/entity.py b/homeassistant/components/permobil/entity.py deleted file mode 100644 index 702781aa361e..000000000000 --- a/homeassistant/components/permobil/entity.py +++ /dev/null @@ -1,29 +0,0 @@ -"""PermobilEntity class.""" - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import MyPermobilCoordinator - - -class PermobilEntity(CoordinatorEntity[MyPermobilCoordinator]): - """Representation of a permobil Entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MyPermobilCoordinator, - description: EntityDescription, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.p_api.email}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.p_api.email)}, - manufacturer="Permobil", - name="Permobil Wheelchair", - ) diff --git a/homeassistant/components/permobil/icons.json b/homeassistant/components/permobil/icons.json deleted file mode 100644 index 53bddcc00a97..000000000000 --- a/homeassistant/components/permobil/icons.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "entity": { - "sensor": { - "charge_time_left": { - "default": "mdi:battery-clock" - }, - "distance_left": { - "default": "mdi:map-marker-distance" - }, - "max_distance_left": { - "default": "mdi:map-marker-distance" - }, - "max_watt_hours": { - "default": "mdi:lightning-bolt" - }, - "record_adjustments": { - "default": "mdi:seat-recline-extra" - }, - "record_distance": { - "default": "mdi:map-marker-distance" - }, - "state_of_health": { - "default": "mdi:battery-heart-variant" - }, - "usage_adjustments": { - "default": "mdi:seat-recline-extra" - }, - "usage_distance": { - "default": "mdi:map-marker-distance" - }, - "watt_hours_left": { - "default": "mdi:lightning-bolt" - } - } - } -} diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json index 7bba8182c04a..ab37dd5d0ed6 100644 --- a/homeassistant/components/permobil/manifest.json +++ b/homeassistant/components/permobil/manifest.json @@ -1,10 +1,10 @@ { "domain": "permobil", "name": "MyPermobil", - "codeowners": ["@IsakNyberg"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/permobil", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["mypermobil==0.1.8"] + "quality_scale": "legacy", + "requirements": [] } diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py deleted file mode 100644 index a1c35e034023..000000000000 --- a/homeassistant/components/permobil/sensor.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Platform for sensor integration.""" - -from collections.abc import Callable -from dataclasses import dataclass -import logging -from typing import Any, override - -from mypermobil import ( - BATTERY_AMPERE_HOURS_LEFT, - BATTERY_CHARGE_TIME_LEFT, - BATTERY_DISTANCE_LEFT, - BATTERY_INDOOR_DRIVE_TIME, - BATTERY_MAX_AMPERE_HOURS, - BATTERY_MAX_DISTANCE_LEFT, - BATTERY_STATE_OF_CHARGE, - BATTERY_STATE_OF_HEALTH, - RECORDS_DISTANCE, - RECORDS_DISTANCE_UNIT, - RECORDS_SEATING, - USAGE_ADJUSTMENTS, - USAGE_DISTANCE, -) - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import BATTERY_ASSUMED_VOLTAGE, KM, MILES -from .coordinator import PermobilConfigEntry -from .entity import PermobilEntity - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class PermobilSensorEntityDescription(SensorEntityDescription): - """Describes Permobil sensor entity.""" - - value_fn: Callable[[Any], float | int] - available_fn: Callable[[Any], bool] - - -SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( - PermobilSensorEntityDescription( - # Current battery as a percentage - value_fn=lambda data: data.battery[BATTERY_STATE_OF_CHARGE[0]], - available_fn=lambda data: BATTERY_STATE_OF_CHARGE[0] in data.battery, - key="state_of_charge", - translation_key="state_of_charge", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Current battery health as a percentage of original capacity - value_fn=lambda data: data.battery[BATTERY_STATE_OF_HEALTH[0]], - available_fn=lambda data: BATTERY_STATE_OF_HEALTH[0] in data.battery, - key="state_of_health", - translation_key="state_of_health", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Time until fully charged (displays 0 if not charging) - value_fn=lambda data: data.battery[BATTERY_CHARGE_TIME_LEFT[0]], - available_fn=lambda data: BATTERY_CHARGE_TIME_LEFT[0] in data.battery, - key="charge_time_left", - translation_key="charge_time_left", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - ), - PermobilSensorEntityDescription( - # Distance possible on current change (km) - value_fn=lambda data: data.battery[BATTERY_DISTANCE_LEFT[0]], - available_fn=lambda data: BATTERY_DISTANCE_LEFT[0] in data.battery, - key="distance_left", - translation_key="distance_left", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - ), - PermobilSensorEntityDescription( - # Drive time possible on current charge - value_fn=lambda data: data.battery[BATTERY_INDOOR_DRIVE_TIME[0]], - available_fn=lambda data: BATTERY_INDOOR_DRIVE_TIME[0] in data.battery, - key="indoor_drive_time", - translation_key="indoor_drive_time", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - ), - PermobilSensorEntityDescription( - # Watt hours the battery can store given battery health - value_fn=lambda data: ( - data.battery[BATTERY_MAX_AMPERE_HOURS[0]] * BATTERY_ASSUMED_VOLTAGE - ), - available_fn=lambda data: BATTERY_MAX_AMPERE_HOURS[0] in data.battery, - key="max_watt_hours", - translation_key="max_watt_hours", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY_STORAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Current amount of watt hours in battery - value_fn=lambda data: ( - data.battery[BATTERY_AMPERE_HOURS_LEFT[0]] * BATTERY_ASSUMED_VOLTAGE - ), - available_fn=lambda data: BATTERY_AMPERE_HOURS_LEFT[0] in data.battery, - key="watt_hours_left", - translation_key="watt_hours_left", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY_STORAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Distance that can be traveled with full charge given battery health (km) - value_fn=lambda data: data.battery[BATTERY_MAX_DISTANCE_LEFT[0]], - available_fn=lambda data: BATTERY_MAX_DISTANCE_LEFT[0] in data.battery, - key="max_distance_left", - translation_key="max_distance_left", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - ), - PermobilSensorEntityDescription( - # Distance traveled today monotonically increasing, resets every 24h (km) - value_fn=lambda data: data.daily_usage[USAGE_DISTANCE[0]], - available_fn=lambda data: USAGE_DISTANCE[0] in data.daily_usage, - key="usage_distance", - translation_key="usage_distance", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Number of adjustments monotonically increasing, resets every 24h - value_fn=lambda data: data.daily_usage[USAGE_ADJUSTMENTS[0]], - available_fn=lambda data: USAGE_ADJUSTMENTS[0] in data.daily_usage, - key="usage_adjustments", - translation_key="usage_adjustments", - native_unit_of_measurement="adjustments", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Largest number of adjustments in a single 24h period, - # monotonically increasing, never resets - value_fn=lambda data: data.records[RECORDS_SEATING[0]], - available_fn=lambda data: RECORDS_SEATING[0] in data.records, - key="record_adjustments", - translation_key="record_adjustments", - native_unit_of_measurement="adjustments", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Record of largest distance travelled in a day, - # monotonically increasing, never resets - value_fn=lambda data: data.records[RECORDS_DISTANCE[0]], - available_fn=lambda data: RECORDS_DISTANCE[0] in data.records, - key="record_distance", - translation_key="record_distance", - device_class=SensorDeviceClass.DISTANCE, - state_class=SensorStateClass.TOTAL_INCREASING, - ), -) - -DISTANCE_UNITS: dict[Any, UnitOfLength] = { - KM: UnitOfLength.KILOMETERS, - MILES: UnitOfLength.MILES, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: PermobilConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Create sensors from a config entry created in the integrations UI.""" - - coordinator = config_entry.runtime_data - - async_add_entities( - PermobilSensor(coordinator=coordinator, description=description) - for description in SENSOR_DESCRIPTIONS - ) - - -class PermobilSensor(PermobilEntity, SensorEntity): - """Representation of a Sensor. - - This implements the common functions of all sensors. - """ - - _attr_suggested_display_precision = 0 - entity_description: PermobilSensorEntityDescription - - @property - @override - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor.""" - if self.entity_description.key == "record_distance": - return DISTANCE_UNITS.get( - self.coordinator.data.records[RECORDS_DISTANCE_UNIT[0]] - ) - return self.entity_description.native_unit_of_measurement - - @property - @override - def available(self) -> bool: - """Return True if the sensor has value.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) - - @property - @override - def native_value(self) -> float | int: - """Return the value of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json index 12adf6dff5a7..5bbfe044bf5e 100644 --- a/homeassistant/components/permobil/strings.json +++ b/homeassistant/components/permobil/strings.json @@ -1,81 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "error": { - "code_request_error": "Error requesting application code", - "invalid_code": "The code you gave is incorrect", - "invalid_email": "Invalid email", - "region_fetch_error": "Error fetching regions", - "unknown": "Unexpected error, more information in the logs", - "unsigned_eula": "Please sign the EULA in the {app_name} app" - }, - "step": { - "email_code": { - "data": { - "code": "Email code" - }, - "description": "Enter the code that was sent to your email." - }, - "region": { - "data": { - "region": "Region" - }, - "description": "Select the region of your account." - }, - "user": { - "data": { - "email": "Enter your permobil email" - } - } - } - }, - "entity": { - "binary_sensor": { - "is_charging": { - "name": "Is charging" - } - }, - "sensor": { - "charge_time_left": { - "name": "Charge time left" - }, - "distance_left": { - "name": "Distance left" - }, - "indoor_drive_time": { - "name": "Indoor drive time" - }, - "max_distance_left": { - "name": "Full charge distance" - }, - "max_watt_hours": { - "name": "Battery max watt hours" - }, - "record_adjustments": { - "name": "Record number of adjustments" - }, - "record_distance": { - "name": "Record distance" - }, - "state_of_charge": { - "name": "Battery charge" - }, - "state_of_health": { - "name": "Battery health" - }, - "usage_adjustments": { - "name": "Number of adjustments" - }, - "usage_distance": { - "name": "Distance traveled" - }, - "watt_hours_left": { - "name": "Watt hours left" - } + "issues": { + "integration_removed": { + "description": "The Permobil integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Permobil integration entries]({entries}).", + "title": "The Permobil integration has been removed" } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 103a914f62ea..0e8963704c87 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -581,7 +581,6 @@ FLOWS = { "peblar", "peco", "pegel_online", - "permobil", "pglab", "philips_js", "pi_hole", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 46b56b722cd7..5d311722c091 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5326,7 +5326,7 @@ "permobil": { "name": "MyPermobil", "integration_type": "device", - "config_flow": true, + "config_flow": false, "iot_class": "cloud_polling" }, "pge": { diff --git a/requirements_all.txt b/requirements_all.txt index be5c56e0fa60..03b08dcee24b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1636,9 +1636,6 @@ mutesync==0.0.1 # homeassistant.components.mvglive mvg==1.4.0 -# homeassistant.components.permobil -mypermobil==0.1.8 - # homeassistant.components.myuplink myuplink==0.7.0 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index fc43a5b2d0c4..4dc6b019bbc8 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1650,7 +1650,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "panel_iframe", "peco", "pencom", - "permobil", "persistent_notification", "person", "philips_js", diff --git a/script/licenses.py b/script/licenses.py index 01fdf114e5b8..0d4dac8d84b7 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -205,9 +205,6 @@ EXCEPTIONS = { # fmt: off TODO = { "TravisPy": AwesomeVersion("0.3.5"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)'] - "aiocache": AwesomeVersion( - "0.12.3" - ), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved? } # fmt: on diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json index 9adc76144d5f..bc8f5cdc2967 100644 --- a/tests/components/analytics_insights/fixtures/current_data.json +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -1199,7 +1199,6 @@ "fritzbox_netmonitor": 4, "apprise": 2, "drop_connect": 1, - "permobil": 3, "norway_air": 3, "push": 2, "upc_connect": 2, diff --git a/tests/components/permobil/__init__.py b/tests/components/permobil/__init__.py index 56e779eef4d7..1e170cf8edd2 100644 --- a/tests/components/permobil/__init__.py +++ b/tests/components/permobil/__init__.py @@ -1 +1 @@ -"""Tests for the MyPermobil integration.""" +"""Tests for the Permobil integration.""" diff --git a/tests/components/permobil/conftest.py b/tests/components/permobil/conftest.py deleted file mode 100644 index d3630d3f3665..000000000000 --- a/tests/components/permobil/conftest.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Common fixtures for the MyPermobil tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch - -from mypermobil import MyPermobil -import pytest - -from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.permobil.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def my_permobil() -> Mock: - """Mock spec for MyPermobilApi.""" - mock = Mock(spec=MyPermobil) - mock.request_region_names.return_value = {MOCK_REGION_NAME: MOCK_URL} - mock.request_application_token.return_value = MOCK_TOKEN - mock.region = "" - return mock diff --git a/tests/components/permobil/const.py b/tests/components/permobil/const.py deleted file mode 100644 index cb8a0c32f17c..000000000000 --- a/tests/components/permobil/const.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Test constants for Permobil.""" - -MOCK_URL = "https://example.com" -MOCK_REGION_NAME = "region_name" -MOCK_TOKEN = ("a" * 256, "date") diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py deleted file mode 100644 index 9b591e2b9919..000000000000 --- a/tests/components/permobil/test_config_flow.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Test the MyPermobil config flow.""" - -from unittest.mock import Mock, patch - -from mypermobil import ( - MyPermobilAPIException, - MyPermobilClientException, - MyPermobilEulaException, -) -import pytest - -from homeassistant import config_entries -from homeassistant.components.permobil import config_flow -from homeassistant.components.permobil.const import DOMAIN -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL - -from tests.common import MockConfigEntry - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -MOCK_CODE = "012345" -MOCK_EMAIL = "valid@email.com" -INVALID_EMAIL = "this is not a valid email" -VALID_DATA = { - CONF_EMAIL: MOCK_EMAIL, - CONF_REGION: MOCK_URL, - CONF_CODE: MOCK_CODE, - CONF_TOKEN: MOCK_TOKEN[0], - CONF_TTL: MOCK_TOKEN[1], -} - - -async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> None: - """Test the config flow from start to finish with no errors.""" - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - # request region code - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == VALID_DATA - - -async def test_config_flow_incorrect_code( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test email code verification with API error. - - Test the config flow from start to until email code verification - and have the API return API error. - """ - my_permobil.request_application_token.side_effect = MyPermobilAPIException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request region code - # here the request_application_token raises a MyPermobilAPIException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "invalid_code" - - -async def test_config_flow_unsigned_eula( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test email code verification with unsigned eula error. - - Test the config flow from start to until email code verification - and have the API return that the eula is unsigned. - """ - my_permobil.request_application_token.side_effect = MyPermobilEulaException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request region code - # here the request_application_token raises a MyPermobilEulaException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "unsigned_eula" - - # Retry to submit the code again, but this time the user has signed the EULA - with patch.object( - my_permobil, - "request_application_token", - return_value=MOCK_TOKEN, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - - # Now the method should not raise an exception, and you can - # proceed with your assertions - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == VALID_DATA - - -async def test_config_flow_incorrect_region( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test when the user does not exist in the selected region. - - Test the config flow from start to until the request for email - code and have the API return error because there is not user for - that email. - """ - my_permobil.request_application_code.side_effect = MyPermobilAPIException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - # here the request_application_code raises a MyPermobilAPIException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"]["base"] == "code_request_error" - - -async def test_config_flow_region_request_error( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test region request error. - - Test the config flow from start to until the request for regions - and have the API return an error. - """ - my_permobil.request_region_names.side_effect = MyPermobilAPIException - # init flow - # here the request_region_names raises a MyPermobilAPIException - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"]["base"] == "region_fetch_error" - - -async def test_config_flow_invalid_email( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test an incorrectly formatted email. - - Test that the email must be formatted correctly. The schema for the - input should already check for this, but since the API does a - separate check that might not overlap 100% with the schema, - this test is still needed. - """ - my_permobil.set_email.side_effect = MyPermobilClientException() - # init flow - # here the set_email raises a MyPermobilClientException - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: INVALID_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"]["base"] == "invalid_email" - - -async def test_config_flow_reauth_success( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth make sure that the values are replaced.""" - # new token and code - reauth_token = ("b" * 256, "reauth_date") - reauth_code = "567890" - my_permobil.request_application_token.return_value = reauth_token - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request new token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: reauth_code}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_entry.data == { - CONF_EMAIL: MOCK_EMAIL, - CONF_REGION: MOCK_URL, - CONF_CODE: reauth_code, - CONF_TOKEN: reauth_token[0], - CONF_TTL: reauth_token[1], - } - - -async def test_config_flow_reauth_fail_invalid_code( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth when the email code fails.""" - # new code - reauth_invalid_code = "567890" # pretend this code is invalid/incorrect - my_permobil.request_application_token.side_effect = MyPermobilAPIException - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request request new token but have the API return error - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: reauth_invalid_code}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "invalid_code" - - -async def test_config_flow_reauth_fail_code_request( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth.""" - my_permobil.request_application_code.side_effect = MyPermobilAPIException - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - # test the reauth and have request_application_code fail leading to an abort - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/permobil/test_init.py b/tests/components/permobil/test_init.py new file mode 100644 index 000000000000..57b2e69b4f3c --- /dev/null +++ b/tests/components/permobil/test_init.py @@ -0,0 +1,79 @@ +"""Tests for the Permobil integration.""" + +from homeassistant.components.permobil import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_permobil_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Permobil configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From 622d53b45ca365132f9c3157370759bed1b83656 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:40:51 +0200 Subject: [PATCH 495/707] Bump uiprotect to 15.10.0 (#176329) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ecb6f202d85d..9434e93105ff 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.9.0"] + "requirements": ["uiprotect==15.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 03b08dcee24b..70585d6405ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3252,7 +3252,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.9.0 +uiprotect==15.10.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From 4224d8ff2788cfb820b97289a25314a6d370b3f9 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 12 Jul 2026 17:08:23 +0300 Subject: [PATCH 496/707] Bump openai to 2.45.0 (#176335) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/llama_cpp/manifest.json | 2 +- homeassistant/components/open_router/manifest.json | 2 +- homeassistant/components/openai_conversation/manifest.json | 2 +- homeassistant/components/ovhcloud_ai_endpoints/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 72941c5a5523..bd5f79524ac4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==2.2.0", "openai==2.21.0"], + "requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"], "single_config_entry": true } diff --git a/homeassistant/components/llama_cpp/manifest.json b/homeassistant/components/llama_cpp/manifest.json index 1285be7afabf..a610d9027208 100644 --- a/homeassistant/components/llama_cpp/manifest.json +++ b/homeassistant/components/llama_cpp/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 5be81a48a75f..1631b3f9df5b 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0", "python-open-router==0.3.3"] + "requirements": ["openai==2.45.0", "python-open-router==0.3.3"] } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 7460bf938a7f..95fb8fc7d211 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json index f2393ec1ade1..93feba804811 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json +++ b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2308d37fb0dc..cd2b9ffc2b6b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 -openai==2.21.0 +openai==2.45.0 orjson==3.11.9 packaging>=23.1 paho-mqtt==2.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 70585d6405ba..6abecac0a27b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1768,7 +1768,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation # homeassistant.components.ovhcloud_ai_endpoints -openai==2.21.0 +openai==2.45.0 # homeassistant.components.openerz openerz-api==0.3.0 From 75adfde43608d1277c91db124c8989605a32715e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Sch=C3=B6ppach?= Date: Sun, 12 Jul 2026 16:32:56 +0200 Subject: [PATCH 497/707] Fix VeSync crash on fan levels outside of defined list (#176234) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/vesync/fan.py | 14 +++++------ tests/components/vesync/common.py | 19 ++++++++++++--- tests/components/vesync/test_fan.py | 33 ++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 3d10be4c647f..f803fd121c0b 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -163,15 +163,15 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): """Return the currently set speed.""" current_level = self.device.state.fan_level - if ( - self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL) - and current_level is not None - ): + if self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL): if current_level == 0: return 0 - return ordered_list_item_to_percentage( - self.device.fan_levels, current_level - ) + # The device can report an out-of-range level (e.g. -1) when the + # speed is not applicable; treat it as unknown instead of crashing. + if current_level in self.device.fan_levels: + return ordered_list_item_to_percentage( + self.device.fan_levels, current_level + ) return None @property diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 07076e1dd8cb..017b2a203543 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -99,9 +99,15 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { def mock_devices_response( - aioclient_mock: AiohttpClientMocker, device_name: str + aioclient_mock: AiohttpClientMocker, + device_name: str, + details_override: dict[str, Any] | None = None, ) -> None: - """Build a response for the Helpers.call_api method.""" + """Build a response for the Helpers.call_api method. + + ``details_override`` is merged into the nested ``result`` payload of the + device detail response, allowing tests to simulate specific device states. + """ device_list = [ device for device in ALL_DEVICES["result"]["list"] @@ -126,9 +132,16 @@ def mock_devices_response( ) for fixture in DEVICE_FIXTURES[device_name]: + detail = load_json_object_fixture(fixture[2], DOMAIN) + if details_override: + assert "result" in detail.get("result", {}), ( + f"Fixture {fixture[2]} does not have the expected " + "result.result payload to apply details_override" + ) + detail["result"]["result"].update(details_override) getattr(aioclient_mock, fixture[0])( f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture(fixture[2], DOMAIN), + json=detail, ) mock_firmware(aioclient_mock) diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 03f862088fc4..436ab852f52e 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -6,8 +6,17 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -188,6 +197,26 @@ async def test_set_preset_mode( update_mock.assert_called_once() +async def test_out_of_range_fan_level( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that an out-of-range fan level produces an unknown percentage.""" + + mock_devices_response( + aioclient_mock, "CoreBreeze 432S", details_override={"fanSpeedLevel": -1} + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_PEDESTAL_FAN) + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.attributes[ATTR_PERCENTAGE] is None + + @pytest.mark.parametrize( ("action", "api_response", "expectation"), [ From 29c4629cd0f931b1702fdc5fd020248f69300ae1 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sun, 12 Jul 2026 16:33:55 +0200 Subject: [PATCH 498/707] Make BSB-LAN slow startup refresh nonblocking (#176294) --- homeassistant/components/bsblan/__init__.py | 11 +++-- .../components/bsblan/water_heater.py | 41 +++++++++++++------ tests/components/bsblan/test_init.py | 32 +++++++++++++++ tests/components/bsblan/test_water_heater.py | 36 ++++++++++++++++ 4 files changed, 104 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0000fbf09112..6966beb7d3e4 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -257,10 +257,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo # Perform first refresh of fast coordinator (required for entities) await fast_coordinator.async_config_entry_first_refresh() - # Refresh slow coordinator - don't fail if DHW is not available - # This allows the integration to work even if the device doesn't support DHW - await slow_coordinator.async_refresh() - entry.runtime_data = BSBLanData( client=bsblan, fast_coordinator=fast_coordinator, @@ -271,6 +267,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo available_circuits=circuits, ) + # Fetch slow data in the background so it does not block startup. + entry.async_create_background_task( + hass, + slow_coordinator.async_refresh(), + name=f"{DOMAIN}_slow_data_fetch_{entry.entry_id}", + ) + # Register main device before forwarding platforms, so sub-devices # (heating circuits, water heater) can reference it via via_device device_registry = dr.async_get(hass) diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index 518b6e4dcc45..8c0f8b37cdf1 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -80,32 +80,49 @@ class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity): # Initialize available attribute to resolve multiple inheritance conflict self._attr_available = True - # Set temperature limits based on device capabilities from slow coordinator + @property + @override + def min_temp(self) -> float: + """Return the minimum temperature. + + Derived from the slow-coordinator DHW config, which may still be + pending when the platform is set up. Falls back to the default until + the config becomes available. + """ dhw_config = ( - data.slow_coordinator.data.dhw_config - if data.slow_coordinator.data + self.slow_coordinator.data.dhw_config + if self.slow_coordinator.data else None ) - - # For min_temp: Use reduced_setpoint from config data (slow polling) if ( dhw_config is not None and dhw_config.reduced_setpoint is not None and dhw_config.reduced_setpoint.value is not None ): - self._attr_min_temp = dhw_config.reduced_setpoint.value - else: - self._attr_min_temp = 10.0 # Default minimum + return dhw_config.reduced_setpoint.value + return 10.0 # Default minimum - # For max_temp: Use nominal_setpoint_max from config data (slow polling) + @property + @override + def max_temp(self) -> float: + """Return the maximum temperature. + + Derived from the slow-coordinator DHW config, which may still be + pending when the platform is set up. Falls back to the default until + the config becomes available. + """ + dhw_config = ( + self.slow_coordinator.data.dhw_config + if self.slow_coordinator.data + else None + ) if ( dhw_config is not None and dhw_config.nominal_setpoint_max is not None and dhw_config.nominal_setpoint_max.value is not None ): - self._attr_max_temp = dhw_config.nominal_setpoint_max.value - else: - self._attr_max_temp = 65.0 # Default maximum + return dhw_config.nominal_setpoint_max.value + return 65.0 # Default maximum @property def _dhw(self) -> HotWaterState: diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index 9ec11356c596..2b1fac2eef83 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -1,5 +1,6 @@ """Tests for the BSBLan integration.""" +import asyncio from datetime import timedelta from unittest.mock import MagicMock @@ -291,6 +292,37 @@ async def test_coordinator_dhw_config_update_error( assert mock_bsblan.hot_water_schedule.called +async def test_setup_does_not_block_on_slow_fetch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test setup does not wait for the background slow-data fetch.""" + release = asyncio.Event() + config_value = mock_bsblan.hot_water_config.return_value + + async def _blocking_config(*args: object, **kwargs: object) -> object: + await release.wait() + return config_value + + mock_bsblan.hot_water_config.side_effect = _blocking_config + + mock_config_entry.add_to_hass(hass) + try: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Setup finished even though the slow-data fetch is still pending. + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not mock_bsblan.hot_water_schedule.called + finally: + # Release the fetch so it can complete and clean up. + release.set() + await hass.async_block_till_done() + + assert mock_bsblan.hot_water_schedule.called + + async def test_coordinator_slow_first_fetch_failure( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index b730ddad688f..d7ef7f0718f3 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -1,5 +1,6 @@ """Tests for the BSB-LAN water heater platform.""" +import asyncio from datetime import timedelta from unittest.mock import AsyncMock, MagicMock @@ -392,6 +393,41 @@ async def test_water_heater_custom_temperature_limits_from_config( ) # Custom maximum from nominal_setpoint_max +async def test_water_heater_temperature_limits_update_after_slow_fetch( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test temperature limits update when the background fetch completes.""" + release = asyncio.Event() + config = mock_bsblan.hot_water_config.return_value + config.reduced_setpoint.value = 15.0 + config.nominal_setpoint_max.value = 75.0 + + async def _blocking_config(*args: object, **kwargs: object) -> object: + await release.wait() + return config + + mock_bsblan.hot_water_config.side_effect = _blocking_config + + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 10.0 + assert state.attributes["max_temp"] == 65.0 + + release.set() + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 15.0 + assert state.attributes["max_temp"] == 75.0 + + async def test_turn_on( hass: HomeAssistant, mock_bsblan: AsyncMock, From 40451bdeb90ac2f2bee2106eac2ba763eb002d72 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 12 Jul 2026 16:36:46 +0200 Subject: [PATCH 499/707] Filter out grinders during CF in lamarzocco (#176289) --- .../components/lamarzocco/config_flow.py | 7 ++++- .../components/lamarzocco/test_config_flow.py | 29 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 3a85a01c3fc8..a072b6edeff3 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -7,6 +7,7 @@ import uuid from aiohttp import ClientSession from pylamarzocco import LaMarzoccoCloudClient +from pylamarzocco.const import DeviceType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import Thing from pylamarzocco.util import InstallationKey, generate_installation_key @@ -105,7 +106,11 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error connecting to server: %s", exc) errors["base"] = "cannot_connect" else: - self._things = {thing.serial_number: thing for thing in things} + self._things = { + thing.serial_number: thing + for thing in things + if thing.type is DeviceType.MACHINE + } if not self._things: errors["base"] = "no_machines" diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 5106b6db6e99..5af778f31d3a 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -4,8 +4,9 @@ from collections.abc import Generator from copy import deepcopy from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import DeviceType, ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import Thing import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE @@ -35,7 +36,7 @@ from . import ( get_bluetooth_service_info, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture(autouse=True) @@ -197,6 +198,30 @@ async def test_form_no_machines( await __do_sucessful_machine_selection_step(hass, result) +async def test_grinders_not_configurable( + hass: HomeAssistant, + mock_cloud_client: MagicMock, +) -> None: + """Test that grinders are filtered out so only machines can be configured.""" + grinder = await async_load_json_object_fixture(hass, "thing.json", DOMAIN) + grinder["type"] = DeviceType.GRINDER + grinder["serialNumber"] = "GR012345" + grinder["name"] = "GR012345" + + mock_cloud_client.list_things.return_value = [ + *mock_cloud_client.list_things.return_value, + Thing.from_dict(grinder), + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await __do_successful_user_step(hass, result, mock_cloud_client) + + options = result["data_schema"].schema[CONF_MACHINE].config["options"] + assert [option["value"] for option in options] == ["GS012345"] + + async def test_reauth_flow( hass: HomeAssistant, mock_cloud_client: MagicMock, From c06e112b5e28b66b6ac97637aa6e05077c10b0de Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:53:10 +0200 Subject: [PATCH 500/707] Use base EntityStateAttribute for geo_location coordinates (#176189) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/geo_location/__init__.py | 10 +++++++--- homeassistant/components/geo_location/const.py | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 743bc3ede71d..35a5f5c1a51a 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -7,7 +7,11 @@ from typing import Any, final, override from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE # noqa: F401 +from homeassistant.const import ( # noqa: F401 + ATTR_LATITUDE, + ATTR_LONGITUDE, + EntityStateAttribute, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity @@ -105,7 +109,7 @@ class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the state attributes of this external event.""" data: dict[str, Any] = {GeolocationEntityStateAttribute.SOURCE: self.source} if self.latitude is not None: - data[GeolocationEntityStateAttribute.LATITUDE] = round(self.latitude, 5) + data[EntityStateAttribute.LATITUDE] = round(self.latitude, 5) if self.longitude is not None: - data[GeolocationEntityStateAttribute.LONGITUDE] = round(self.longitude, 5) + data[EntityStateAttribute.LONGITUDE] = round(self.longitude, 5) return data diff --git a/homeassistant/components/geo_location/const.py b/homeassistant/components/geo_location/const.py index 8cd7194e8551..6d1513ccb714 100644 --- a/homeassistant/components/geo_location/const.py +++ b/homeassistant/components/geo_location/const.py @@ -2,10 +2,19 @@ from enum import StrEnum +from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers -class GeolocationEntityStateAttribute(StrEnum): + +class GeolocationEntityStateAttribute( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "LATITUDE": ("EntityStateAttribute.LATITUDE", "2027.2.0"), + "LONGITUDE": ("EntityStateAttribute.LONGITUDE", "2027.2.0"), + }, +): """State attributes for geolocation entities.""" SOURCE = "source" - LATITUDE = "latitude" - LONGITUDE = "longitude" + LATITUDE = "latitude" # Deprecated, replaced with EntityStateAttribute.LATITUDE + LONGITUDE = "longitude" # Deprecated, replaced with EntityStateAttribute.LONGITUDE From ce409325080d7d3b68c79df10942027f941b4f9b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:36:54 +0200 Subject: [PATCH 501/707] Use entity state attribute enums in OwnTracks (#175981) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/owntracks/__init__.py | 24 +++++++++---------- .../components/owntracks/device_tracker.py | 19 +++++++-------- .../components/owntracks/messages.py | 9 +++---- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index d76d07a8b82e..6f3ba4b48511 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -10,14 +10,9 @@ from aiohttp import web import voluptuous as vol from homeassistant.components import cloud, mqtt, webhook +from homeassistant.components.device_tracker import TrackerEntityStateAttribute from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_WEBHOOK_ID, - Platform, -) +from homeassistant.const import CONF_WEBHOOK_ID, EntityStateAttribute, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -194,13 +189,14 @@ async def handle_webhook( response = [ { "_type": "location", - "lat": person.attributes["latitude"], - "lon": person.attributes["longitude"], + "lat": person.attributes[EntityStateAttribute.LATITUDE], + "lon": person.attributes[EntityStateAttribute.LONGITUDE], "tid": "".join(p[0] for p in person.name.split(" ")[:2]), "tst": int(person.last_updated.timestamp()), } for person in hass.states.async_all("person") - if "latitude" in person.attributes and "longitude" in person.attributes + if EntityStateAttribute.LATITUDE in person.attributes + and EntityStateAttribute.LONGITUDE in person.attributes ] if message["_type"] == "encrypted" and context.secret: @@ -297,9 +293,11 @@ class OwnTracksContext: device_tracker_state = hass.states.get(f"device_tracker.{dev_id}") if device_tracker_state is not None: - acc = device_tracker_state.attributes.get(ATTR_GPS_ACCURACY) - lat = device_tracker_state.attributes.get(ATTR_LATITUDE) - lon = device_tracker_state.attributes.get(ATTR_LONGITUDE) + acc = device_tracker_state.attributes.get( + TrackerEntityStateAttribute.GPS_ACCURACY + ) + lat = device_tracker_state.attributes.get(EntityStateAttribute.LATITUDE) + lon = device_tracker_state.attributes.get(EntityStateAttribute.LONGITUDE) if lat is not None and lon is not None: kwargs["gps"] = (lat, lon) diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 3711b60f9cfe..477bf74c4cfa 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -4,18 +4,14 @@ from typing import Any, override from homeassistant.components.device_tracker import ( - ATTR_SOURCE_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, SourceType, TrackerEntity, + TrackerEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, -) +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -177,10 +173,13 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): self._data = { "host_name": state.name, - "gps": (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), - "gps_accuracy": attr.get(ATTR_GPS_ACCURACY), + "gps": ( + attr.get(EntityStateAttribute.LATITUDE), + attr.get(EntityStateAttribute.LONGITUDE), + ), + "gps_accuracy": attr.get(TrackerEntityStateAttribute.GPS_ACCURACY), "battery": attr.get(ATTR_BATTERY_LEVEL), - "source_type": attr.get(ATTR_SOURCE_TYPE), + "source_type": attr.get(DeviceTrackerEntityStateAttribute.SOURCE_TYPE), "attributes": attributes, } diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index b59ec84749d8..668b13515481 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -8,7 +8,8 @@ from nacl.secret import SecretBox from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import SourceType -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME +from homeassistant.components.zone import ZoneEntityStateAttribute +from homeassistant.const import STATE_HOME, EntityStateAttribute from homeassistant.util import decorator, dt as dt_util, slugify from .const import ( @@ -108,10 +109,10 @@ def _set_gps_from_zone(kwargs, location, zone): """ if zone is not None: kwargs["gps"] = ( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], ) - kwargs["gps_accuracy"] = zone.attributes["radius"] + kwargs["gps_accuracy"] = zone.attributes[ZoneEntityStateAttribute.RADIUS] kwargs["location_name"] = location return kwargs From b4665cc0eb42ece99d3aad06fdd15f3e10af7d51 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:37:25 +0200 Subject: [PATCH 502/707] Use state attribute enums in Teslemetry (#175970) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/teslemetry/device_tracker.py | 5 +-- .../components/teslemetry/media_player.py | 33 ++++++++++++++----- homeassistant/components/teslemetry/update.py | 22 ++++++++++--- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 4e8c360080f1..97dcd8b1515d 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -13,6 +13,7 @@ from homeassistant.components.device_tracker import ( TrackerEntity, TrackerEntityDescription, ) +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -147,8 +148,8 @@ class TeslemetryStreamingDeviceTrackerEntity( """Handle entity which will be added.""" await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: - self._attr_latitude = state.attributes.get("latitude") - self._attr_longitude = state.attributes.get("longitude") + self._attr_latitude = state.attributes.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = state.attributes.get(EntityStateAttribute.LONGITUDE) self.async_on_remove( self.entity_description.value_listener( self.vehicle.stream_vehicle, self._location_callback diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index dbe684963022..5c1d332c103d 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -10,6 +10,7 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, MediaPlayerState, ) from homeassistant.core import HomeAssistant @@ -201,14 +202,30 @@ class TeslemetryStreamingMediaEntity( self._attr_state = MediaPlayerState(state.state) except ValueError: self._attr_state = None - self._attr_volume_level = state.attributes.get("volume_level") - self._attr_media_title = state.attributes.get("media_title") - self._attr_media_artist = state.attributes.get("media_artist") - self._attr_media_album_name = state.attributes.get("media_album_name") - self._attr_media_playlist = state.attributes.get("media_playlist") - self._attr_media_duration = state.attributes.get("media_duration") - self._attr_media_position = state.attributes.get("media_position") - self._attr_source = state.attributes.get("source") + self._attr_volume_level = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL + ) + self._attr_media_title = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_TITLE + ) + self._attr_media_artist = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_ARTIST + ) + self._attr_media_album_name = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME + ) + self._attr_media_playlist = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_PLAYLIST + ) + self._attr_media_duration = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_DURATION + ) + self._attr_media_position = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_POSITION + ) + self._attr_source = state.attributes.get( + MediaPlayerEntityStateAttribute.INPUT_SOURCE + ) self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index d3e552883d7e..c592070bba43 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -6,7 +6,11 @@ from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle -from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityFeature, + UpdateEntityStateAttribute, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -154,10 +158,18 @@ class TeslemetryStreamingUpdateEntity( """Handle entity which will be added.""" await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: - self._attr_in_progress = state.attributes.get("in_progress", False) - self._attr_update_percentage = state.attributes.get("update_percentage") - self._attr_installed_version = state.attributes.get("installed_version") - self._attr_latest_version = state.attributes.get("latest_version") + self._attr_in_progress = state.attributes.get( + UpdateEntityStateAttribute.IN_PROGRESS, False + ) + self._attr_update_percentage = state.attributes.get( + UpdateEntityStateAttribute.UPDATE_PERCENTAGE + ) + self._attr_installed_version = state.attributes.get( + UpdateEntityStateAttribute.INSTALLED_VERSION + ) + self._attr_latest_version = state.attributes.get( + UpdateEntityStateAttribute.LATEST_VERSION + ) self._attr_supported_features = UpdateEntityFeature( state.attributes.get( "supported_features", self._attr_supported_features From 33ef6fd5a40ea21df9e23b826a783ffb2215aade Mon Sep 17 00:00:00 2001 From: Manu Date: Sun, 12 Jul 2026 21:39:28 +0200 Subject: [PATCH 503/707] Correct device info in Steam integration (#176356) --- homeassistant/components/steam_online/const.py | 1 - homeassistant/components/steam_online/entity.py | 5 +++-- tests/components/steam_online/snapshots/test_init.ambr | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/steam_online/const.py b/homeassistant/components/steam_online/const.py index 89d305419b84..c5f1233d3723 100644 --- a/homeassistant/components/steam_online/const.py +++ b/homeassistant/components/steam_online/const.py @@ -6,7 +6,6 @@ CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" DATA_KEY_COORDINATOR = "coordinator" -DEFAULT_NAME = "Steam" DOMAIN: Final = "steam_online" diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py index ac62ce76ca45..2c2732ebfa7a 100644 --- a/homeassistant/components/steam_online/entity.py +++ b/homeassistant/components/steam_online/entity.py @@ -4,7 +4,7 @@ from homeassistant.components.sensor import SensorEntityDescription from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .coordinator import SteamDataUpdateCoordinator @@ -28,6 +28,7 @@ class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]): configuration_url=str(coordinator.data[steamid].profileurl), entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, steamid)}, - manufacturer=DEFAULT_NAME, + model="Steam", + manufacturer="Valve", name=str(coordinator.data[steamid].personaname), ) diff --git a/tests/components/steam_online/snapshots/test_init.ambr b/tests/components/steam_online/snapshots/test_init.ambr index 62d11b8f6b6d..9cec5ffc35b0 100644 --- a/tests/components/steam_online/snapshots/test_init.ambr +++ b/tests/components/steam_online/snapshots/test_init.ambr @@ -19,8 +19,8 @@ }), 'labels': set({ }), - 'manufacturer': 'Steam', - 'model': None, + 'manufacturer': 'Valve', + 'model': 'Steam', 'model_id': None, 'name': 'testaccount1', 'name_by_user': None, From 81ceb7156a3709d67717a91a4e4f61bb8fc35519 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 12 Jul 2026 23:42:07 +0200 Subject: [PATCH 504/707] Bump imgw-pib to 2.4.3 (#176358) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 2ff27cbb0259..8cc18fbd7dd0 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["imgw_pib==2.4.0"] + "requirements": ["imgw_pib==2.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6abecac0a27b..4426a66bf80e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1347,7 +1347,7 @@ ihcsdk==2.8.12 imeon_inverter_api==0.4.0 # homeassistant.components.imgw_pib -imgw_pib==2.4.0 +imgw_pib==2.4.3 # homeassistant.components.incomfort incomfort-client==0.7.0 From aaeacf45194492b7451d5398762ea7e2da2614ef Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 13 Jul 2026 07:43:29 +1000 Subject: [PATCH 505/707] Bump tesla-fleet-api to 1.7.2 (#176325) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 8929dc0be85b..300c29ad0b3a 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.7.1"] + "requirements": ["tesla-fleet-api==1.7.2"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index e8ac86c3076d..a446a43e20bf 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.7.1", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.7.2", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 0a37f0856c2e..f47f3a18f8dc 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.1"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4426a66bf80e..78b7681db0ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3162,7 +3162,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.7.1 +tesla-fleet-api==1.7.2 # homeassistant.components.powerwall tesla-powerwall==0.5.3 From f416febf0313d8a827377a48b10aa5b0a2ed3807 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Mon, 13 Jul 2026 06:51:27 +0800 Subject: [PATCH 506/707] Bump python-izone to 1.3.4 (#176341) --- homeassistant/components/izone/climate.py | 24 ++++++++++---------- homeassistant/components/izone/discovery.py | 6 +++++ homeassistant/components/izone/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 618e9036f287..74fab9e02799 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -308,10 +308,10 @@ class ControllerDevice(ClimateEntity): """Return current operation ie. heat, cool, idle.""" if not self._controller.is_on: return HVACMode.OFF - if (mode := self._controller.mode) == Controller.Mode.FREE_AIR: + if (mode := self._controller.mode) is Controller.Mode.FREE_AIR: return HVACMode.FAN_ONLY for key, value in self._state_to_pizone.items(): - if value == mode: + if value is mode: return key raise RuntimeError("Should be unreachable") @@ -345,7 +345,7 @@ class ControllerDevice(ClimateEntity): @override def current_temperature(self) -> float | None: """Return the current temperature.""" - if self._controller.mode == Controller.Mode.FREE_AIR: + if self._controller.mode is Controller.Mode.FREE_AIR: return self._controller.temp_supply return self._controller.temp_return @@ -390,7 +390,7 @@ class ControllerDevice(ClimateEntity): return self.control_zone_setpoint @property - def supply_temperature(self) -> float: + def supply_temperature(self) -> float | None: """Return the current supply, or in duct, temperature.""" return self._controller.temp_supply @@ -488,7 +488,7 @@ class ZoneDevice(ClimateEntity): self._controller = controller self._zone = zone - if zone.type != Zone.Type.AUTO: + if zone.type is not Zone.Type.AUTO: self._state_to_pizone = { HVACMode.OFF: Zone.Mode.CLOSE, HVACMode.FAN_ONLY: Zone.Mode.OPEN, @@ -555,7 +555,7 @@ class ZoneDevice(ClimateEntity): @override def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" - if self._zone.mode == Zone.Mode.AUTO: + if self._zone.mode is Zone.Mode.AUTO: return self._attr_supported_features return self._attr_supported_features & ~ClimateEntityFeature.TARGET_TEMPERATURE @@ -565,7 +565,7 @@ class ZoneDevice(ClimateEntity): """Return current operation ie. heat, cool, idle.""" mode = self._zone.mode for key, value in self._state_to_pizone.items(): - if value == mode: + if value is mode: return key return None @@ -577,7 +577,7 @@ class ZoneDevice(ClimateEntity): @property @override - def current_temperature(self) -> float: + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._zone.temp_current @@ -585,7 +585,7 @@ class ZoneDevice(ClimateEntity): @override def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if self._zone.type != Zone.Type.AUTO: + if self._zone.type is not Zone.Type.AUTO: return None return self._zone.temp_setpoint @@ -628,7 +628,7 @@ class ZoneDevice(ClimateEntity): @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if self._zone.mode != Zone.Mode.AUTO: + if self._zone.mode is not Zone.Mode.AUTO: return if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: await self._controller.wrap_and_catch(self._zone.set_temp_setpoint(temp)) @@ -643,12 +643,12 @@ class ZoneDevice(ClimateEntity): @property def is_on(self) -> bool: """Return true if on.""" - return self._zone.mode != Zone.Mode.CLOSE + return self._zone.mode is not Zone.Mode.CLOSE @override async def async_turn_on(self) -> None: """Turn device on (open zone).""" - if self._zone.type == Zone.Type.AUTO: + if self._zone.type is Zone.Type.AUTO: await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.AUTO)) else: await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.OPEN)) diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index 6103e2e16254..862577307c58 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable import logging +from typing import override import pizone @@ -135,23 +136,28 @@ class DiscoveryService(pizone.Listener): self._idle_stop_handle = None # Listener interface + @override def controller_discovered(self, ctrl: pizone.Controller) -> None: """Handle new controller discovery.""" self.async_schedule_idle_stop() async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCOVERED, ctrl) + @override def controller_disconnected(self, ctrl: pizone.Controller, ex: Exception) -> None: """On disconnect from controller.""" async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCONNECTED, ctrl, ex) + @override def controller_reconnected(self, ctrl: pizone.Controller) -> None: """On reconnect to controller.""" async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_RECONNECTED, ctrl) + @override def controller_update(self, ctrl: pizone.Controller) -> None: """System update message is received from the controller.""" async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_UPDATE, ctrl) + @override def zone_update(self, ctrl: pizone.Controller, zone: pizone.Zone) -> None: """Zone update message is received from the controller.""" async_dispatcher_send(self.hass, DISPATCH_ZONE_UPDATE, ctrl, zone) diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index 47fc9b2f9267..da55de678ce9 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pizone"], - "requirements": ["python-izone==1.2.10"] + "requirements": ["python-izone==1.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 78b7681db0ca..bc37c2d102e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2682,7 +2682,7 @@ python-homewizard-energy==10.1.0 python-hpilo==4.4.3 # homeassistant.components.izone -python-izone==1.2.10 +python-izone==1.3.4 # homeassistant.components.joaoapps_join python-join-api==0.1.1 From de7fcad06de2b324b752d75ff5568c693016398c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 12 Jul 2026 21:30:33 -0700 Subject: [PATCH 507/707] Bump python-roborock to 5.30.0 (#176373) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index e16b446f3f35..b5d4e0614747 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.29.0", + "python-roborock==5.30.0", "vacuum-map-parser-roborock==0.1.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index bc37c2d102e0..a3d2e6365200 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2740,7 +2740,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==5.29.0 +python-roborock==5.30.0 # homeassistant.components.smarttub python-smarttub==0.0.47 From 928981eefaa4df24e141fcd3f1c1906e6bb895c3 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:38:01 +0200 Subject: [PATCH 508/707] Bump uiprotect to 15.12.1 (#176375) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 9434e93105ff..b279a5015c30 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.10.0"] + "requirements": ["uiprotect==15.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3d2e6365200..08702938eb8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3252,7 +3252,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.10.0 +uiprotect==15.12.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From 3720e3c33ba69165a197c9bebdca9af8c7a5b951 Mon Sep 17 00:00:00 2001 From: Manu Date: Mon, 13 Jul 2026 08:17:33 +0200 Subject: [PATCH 509/707] Add @tr4nt0r as integration owner to Steam (#176359) --- CODEOWNERS | 4 ++-- homeassistant/components/steam_online/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e57a00aa5e55..f3cfd1aae06f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1731,8 +1731,8 @@ CLAUDE.md @home-assistant/core /tests/components/starline/ @anonym-tsk /homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST /tests/components/statistics/ @ThomDietrich @gjohansson-ST -/homeassistant/components/steam_online/ @tkdrob -/tests/components/steam_online/ @tkdrob +/homeassistant/components/steam_online/ @tr4nt0r @tkdrob +/tests/components/steam_online/ @tr4nt0r @tkdrob /homeassistant/components/steamist/ @bdraco /tests/components/steamist/ @bdraco /homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS diff --git a/homeassistant/components/steam_online/manifest.json b/homeassistant/components/steam_online/manifest.json index 9da1f3f3c232..6c5210f8f17d 100644 --- a/homeassistant/components/steam_online/manifest.json +++ b/homeassistant/components/steam_online/manifest.json @@ -1,7 +1,7 @@ { "domain": "steam_online", "name": "Steam", - "codeowners": ["@tkdrob"], + "codeowners": ["@tr4nt0r", "@tkdrob"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/steam_online", "integration_type": "service", From 9a150151a1d7cf095bf108352e57c6b544db5251 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 13 Jul 2026 09:18:53 +0200 Subject: [PATCH 510/707] Bump aioacaia to 0.2.0 (#176378) --- homeassistant/components/acaia/binary_sensor.py | 2 +- homeassistant/components/acaia/button.py | 2 +- homeassistant/components/acaia/config_flow.py | 2 +- homeassistant/components/acaia/coordinator.py | 2 +- homeassistant/components/acaia/manifest.json | 2 +- homeassistant/components/acaia/sensor.py | 3 ++- requirements_all.txt | 2 +- tests/components/acaia/conftest.py | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py index 34b8a713de63..979ebf3ed9b4 100644 --- a/homeassistant/components/acaia/binary_sensor.py +++ b/homeassistant/components/acaia/binary_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override -from aioacaia.acaiascale import AcaiaScale +from aioacaia import AcaiaScale from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py index 1e260ee4e74d..120933092375 100644 --- a/homeassistant/components/acaia/button.py +++ b/homeassistant/components/acaia/button.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, override -from aioacaia.acaiascale import AcaiaScale +from aioacaia import AcaiaScale from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py index 6cceb21ff8bc..a52c5a009f80 100644 --- a/homeassistant/components/acaia/config_flow.py +++ b/homeassistant/components/acaia/config_flow.py @@ -3,8 +3,8 @@ import logging from typing import Any, override +from aioacaia.discovery import is_new_scale from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice -from aioacaia.helpers import is_new_scale import voluptuous as vol from homeassistant.components.bluetooth import ( diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py index f9847c31a8d2..06338078dd66 100644 --- a/homeassistant/components/acaia/coordinator.py +++ b/homeassistant/components/acaia/coordinator.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging from typing import override -from aioacaia.acaiascale import AcaiaScale +from aioacaia import AcaiaScale from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError from homeassistant.components.bluetooth import async_get_scanner diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index e6fdea2600e2..25472715d469 100644 --- a/homeassistant/components/acaia/manifest.json +++ b/homeassistant/components/acaia/manifest.json @@ -26,5 +26,5 @@ "iot_class": "local_push", "loggers": ["aioacaia"], "quality_scale": "platinum", - "requirements": ["aioacaia==0.1.18"] + "requirements": ["aioacaia==0.2.0"] } diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py index 735aacd78d59..0c74f50871bd 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -4,8 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override -from aioacaia.acaiascale import AcaiaDeviceState, AcaiaScale +from aioacaia import AcaiaScale from aioacaia.const import UnitMass as AcaiaUnitOfMass +from aioacaia.scale import AcaiaDeviceState from homeassistant.components.sensor import ( RestoreSensor, diff --git a/requirements_all.txt b/requirements_all.txt index 08702938eb8e..1dec7943a0c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,7 +181,7 @@ aio-ownet==0.0.5 aio-wattwaechter==1.0.0 # homeassistant.components.acaia -aioacaia==0.1.18 +aioacaia==0.2.0 # homeassistant.components.airq aioairq==0.4.8 diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py index ff151f3b0969..e7b4fc2bf914 100644 --- a/tests/components/acaia/conftest.py +++ b/tests/components/acaia/conftest.py @@ -3,8 +3,8 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from aioacaia.acaiascale import AcaiaDeviceState from aioacaia.const import UnitMass as AcaiaUnitOfMass +from aioacaia.scale import AcaiaDeviceState import pytest from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN From b9fa8bb1a3ecdb50a2d1cad5526c92a70f9b83e8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:39:40 +0200 Subject: [PATCH 511/707] Use entity state attribute enums in person (#175982) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/person/__init__.py | 70 +++++++++++++++------ 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index fc67ed9dd062..1ab6c95f5607 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED from homeassistant.components import persistent_notification, websocket_api from homeassistant.components.device_tracker import ( - ATTR_IN_ZONES, - ATTR_SOURCE_TYPE, - ATTR_TRACKING_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityCapabilityAttribute, + DeviceTrackerEntityStateAttribute, SourceType, + TrackerEntityStateAttribute, TrackingType, ) from homeassistant.components.zone import ENTITY_ID_HOME @@ -475,7 +475,15 @@ class Person( """Register device trackers.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._parse_source_state(state) + self._parse_source_state( + state, + latitude=state.attributes.get(EntityStateAttribute.LATITUDE), + longitude=state.attributes.get(EntityStateAttribute.LONGITUDE), + gps_accuracy=state.attributes.get( + PersonEntityStateAttribute.GPS_ACCURACY + ), + in_zones=state.attributes.get(PersonEntityStateAttribute.IN_ZONES), + ) if self.hass.is_running: # Update person now if hass is already running. @@ -534,10 +542,15 @@ class Person( continue if state.attributes.get( - ATTR_TRACKING_TYPE - ) == TrackingType.CONNECTION and state.attributes.get(ATTR_IN_ZONES): + DeviceTrackerEntityCapabilityAttribute.TRACKING_TYPE + ) == TrackingType.CONNECTION and state.attributes.get( + DeviceTrackerEntityStateAttribute.IN_ZONES + ): latest_connected = _get_latest(latest_connected, state) - elif state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS: + elif ( + state.attributes.get(DeviceTrackerEntityStateAttribute.SOURCE_TYPE) + == SourceType.GPS + ): latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: # Legacy scanner without tracking type @@ -551,7 +564,17 @@ class Person( latest = latest_connected or latest_legacy_home or latest_gps or latest_not_home if latest: - self._parse_source_state(latest) + self._parse_source_state( + latest, + latitude=latest.attributes.get(EntityStateAttribute.LATITUDE), + longitude=latest.attributes.get(EntityStateAttribute.LONGITUDE), + gps_accuracy=latest.attributes.get( + TrackerEntityStateAttribute.GPS_ACCURACY + ), + in_zones=latest.attributes.get( + DeviceTrackerEntityStateAttribute.IN_ZONES + ), + ) else: self._attr_state = None self._source = None @@ -564,17 +587,28 @@ class Person( self.async_write_ha_state() @callback - def _parse_source_state(self, state: State) -> None: - """Parse source state and set person attributes. + def _parse_source_state( + self, + state: State, + *, + latitude: float | None, + longitude: float | None, + gps_accuracy: int | None, + in_zones: list[str] | None, + ) -> None: + """Set person attributes from a source state. - This is a device tracker state or the restored person state. + The coordinates are read by the caller using the enum matching the + source, which is either a device tracker or the restored person state. + An absent ``in_zones`` (``None``) means the source does not report zone + membership. """ self._attr_state = state.state self._source = state.entity_id - self._latitude = state.attributes.get(ATTR_LATITUDE) - self._longitude = state.attributes.get(ATTR_LONGITUDE) - self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) - self._in_zones = state.attributes.get(ATTR_IN_ZONES, []) + self._latitude = latitude + self._longitude = longitude + self._gps_accuracy = gps_accuracy + self._in_zones = in_zones or [] # A legacy scanner (one that doesn't report in_zones) reports "home" # without coordinates. Use the home zone's coordinates for backwards @@ -582,14 +616,14 @@ class Person( # trackers report in_zones and keep their own (possibly absent) # coordinates. if ( - ATTR_IN_ZONES not in state.attributes + in_zones is None and state.state == STATE_HOME and self._latitude is None and self._longitude is None and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) is not None ): - self._latitude = home_zone.attributes.get(ATTR_LATITUDE) - self._longitude = home_zone.attributes.get(ATTR_LONGITUDE) + self._latitude = home_zone.attributes.get(EntityStateAttribute.LATITUDE) + self._longitude = home_zone.attributes.get(EntityStateAttribute.LONGITUDE) @callback def _update_extra_state_attributes(self) -> None: From 99af320a5c7047cfc98f45886c5f3cb3431858a1 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 13 Jul 2026 10:06:22 +0200 Subject: [PATCH 512/707] Add quality scale definition for Overkiz (#175318) --- .../components/overkiz/quality_scale.yaml | 68 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/overkiz/quality_scale.yaml diff --git a/homeassistant/components/overkiz/quality_scale.yaml b/homeassistant/components/overkiz/quality_scale.yaml new file mode 100644 index 000000000000..607b2ee9373c --- /dev/null +++ b/homeassistant/components/overkiz/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + config-flow: done + brands: done + dependency-transparency: done + common-modules: done + has-entity-name: done + action-setup: done + appropriate-polling: done + test-before-configure: done + entity-event-setup: done + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: done + test-before-setup: done + docs-high-level-description: done + config-flow-test-coverage: todo + docs-actions: done + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + runtime-data: done + + # Silver + log-when-unavailable: done + config-entry-unloading: done + reauthentication-flow: done + action-exceptions: done + docs-installation-parameters: done + integration-owner: done + parallel-updates: todo + test-coverage: todo + docs-configuration-parameters: + status: exempt + comment: The integration does not provide configuration parameters. + entity-unavailable: done + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: done + entity-translations: todo + docs-data-update: done + entity-disabled-by-default: done + discovery: done + exception-translations: todo + devices: done + docs-supported-devices: done + icon-translations: todo + docs-known-limitations: done + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: done + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: done + docs-use-cases: todo + + # Platinum + async-dependency: done + strict-typing: done + inject-websession: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4dc6b019bbc8..077d55d1ea6d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -684,7 +684,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "otbr", "otp", "ourgroceries", - "overkiz", "ovo_energy", "owntracks", "p1_monitor", From f567685a8b5d3214cf2f47c623f4eb9592bde6b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:42:43 +0200 Subject: [PATCH 513/707] Use entity state attribute enums in zone conditions and triggers (#175998) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/zone/condition.py | 47 +++++++++++++++------- homeassistant/components/zone/trigger.py | 17 ++++---- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index f831195c4193..7a553cc97f3a 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -5,14 +5,15 @@ from typing import Any, Unpack, cast, override import voluptuous as vol from homeassistant.components.device_tracker import ( - ATTR_IN_ZONES, DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, +) +from homeassistant.components.person import ( + DOMAIN as PERSON_DOMAIN, + PersonEntityStateAttribute, ) -from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.const import ( ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_ENTITY_ID, CONF_FOR, CONF_OPTIONS, @@ -20,6 +21,7 @@ from homeassistant.const import ( CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage @@ -48,7 +50,18 @@ _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { } _CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT}) -_IN_ZONES_DOMAINS = {DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN} + +def _get_in_zones_attribute(state: State) -> str | None: + """Return the in_zones attribute for the tracked entity, or None. + + Only person and device_tracker entities report zone membership; each + exposes it under its own platform enum. Any other domain returns None. + """ + if state.domain == PERSON_DOMAIN: + return PersonEntityStateAttribute.IN_ZONES + if state.domain == DEVICE_TRACKER_DOMAIN: + return DeviceTrackerEntityStateAttribute.IN_ZONES + return None def zone( @@ -88,14 +101,13 @@ def zone( # Prefer the in_zones attribute reported by the entity (e.g. person, # device_tracker) over recomputing membership from coordinates. - if ( - entity.domain in _IN_ZONES_DOMAINS - and (in_zones := entity.attributes.get(ATTR_IN_ZONES)) is not None - ): + if (in_zones_attr := _get_in_zones_attribute(entity)) is not None and ( + in_zones := entity.attributes.get(in_zones_attr) + ) is not None: return zone_ent.entity_id in in_zones - latitude = entity.attributes.get(ATTR_LATITUDE) - longitude = entity.attributes.get(ATTR_LONGITUDE) + latitude = entity.attributes.get(EntityStateAttribute.LATITUDE) + longitude = entity.attributes.get(EntityStateAttribute.LONGITUDE) if latitude is None: raise ConditionErrorMessage( @@ -178,8 +190,10 @@ class ZoneCondition(Condition): _DOMAIN_SPECS: dict[str, DomainSpec] = { - "person": DomainSpec(value_source=ATTR_IN_ZONES), - "device_tracker": DomainSpec(value_source=ATTR_IN_ZONES), + PERSON_DOMAIN: DomainSpec(value_source=PersonEntityStateAttribute.IN_ZONES), + DEVICE_TRACKER_DOMAIN: DomainSpec( + value_source=DeviceTrackerEntityStateAttribute.IN_ZONES + ), } _ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( @@ -205,8 +219,11 @@ class _ZoneTargetConditionBase(EntityConditionBase): def _in_target_zone(self, entity_state: State) -> bool: """Check if the entity is currently in the selected zone.""" - in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or () - return self._zone in in_zones + if (in_zones_attr := _get_in_zones_attribute(entity_state)) and ( + in_zones := entity_state.attributes.get(in_zones_attr) + ): + return self._zone in in_zones + return False class InZoneCondition(_ZoneTargetConditionBase): diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index f9fa1f9aea8b..eaf36ed40e41 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -5,15 +5,14 @@ from typing import TYPE_CHECKING, Any, cast, override import voluptuous as vol -from homeassistant.components.device_tracker import ATTR_IN_ZONES from homeassistant.const import ( - ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_EVENT, CONF_FOR, CONF_OPTIONS, CONF_TARGET, CONF_ZONE, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -45,7 +44,7 @@ from homeassistant.helpers.trigger import ( from homeassistant.helpers.typing import ConfigType from . import condition -from .condition import _IN_ZONES_DOMAINS +from .condition import _get_in_zones_attribute from .const import DOMAIN EVENT_ENTER = "enter" @@ -65,7 +64,8 @@ def _state_has_zone_info(state: State) -> bool: tracker); other entities are matched by their coordinates. """ return location.has_location(state) or ( - state.domain in _IN_ZONES_DOMAINS and ATTR_IN_ZONES in state.attributes + (in_zones_attr := _get_in_zones_attribute(state)) is not None + and in_zones_attr in state.attributes ) @@ -168,7 +168,7 @@ class LegacyZoneTrigger(Trigger): if (event == EVENT_ENTER and not from_match and to_match) or ( event == EVENT_LEAVE and from_match and not to_match ): - description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" + description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[EntityStateAttribute.FRIENDLY_NAME]}" run_action( { "entity_id": entity, @@ -199,8 +199,11 @@ class ZoneTriggerBase(EntityTriggerBase): def _in_target_zone(self, state: State) -> bool: """Check if the entity is in the selected zone.""" - in_zones = state.attributes.get(ATTR_IN_ZONES) or () - return self._zone in in_zones + if (in_zones_attr := _get_in_zones_attribute(state)) and ( + in_zones := state.attributes.get(in_zones_attr) + ): + return self._zone in in_zones + return False class EnteredZoneTrigger(ZoneTriggerBase): From 96845589314d8dbfd584906cd9828967a7f18952 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Mon, 13 Jul 2026 12:18:47 +0200 Subject: [PATCH 514/707] Add Netatmo quality scale bronze (#176003) Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../components/netatmo/manifest.json | 1 + .../components/netatmo/quality_scale.yaml | 70 +++++++++++++++++++ homeassistant/components/netatmo/strings.json | 13 ++++ script/hassfest/quality_scale.py | 2 - 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/netatmo/quality_scale.yaml diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 83375b245ee2..51b827f1edb9 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,6 +12,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], + "quality_scale": "bronze", "requirements": ["pyatmo==9.4.0"], "single_config_entry": true } diff --git a/homeassistant/components/netatmo/quality_scale.yaml b/homeassistant/components/netatmo/quality_scale.yaml new file mode 100644 index 000000000000..e6d59d83b40e --- /dev/null +++ b/homeassistant/components/netatmo/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: >- + The integration is set up via an OAuth2 flow that has no user-entered + connection parameters to validate before configuration. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: todo + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 728381c7d33f..ad5d45da49a9 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -181,6 +181,15 @@ "mode": "Calculation", "show_on_map": "Show on map" }, + "data_description": { + "area_name": "Name used to identify this weather area.", + "lat_ne": "Latitude of the northeast corner of the area.", + "lat_sw": "Latitude of the southwest corner of the area.", + "lon_ne": "Longitude of the northeast corner of the area.", + "lon_sw": "Longitude of the southwest corner of the area.", + "mode": "How values from multiple stations in the area are aggregated.", + "show_on_map": "Whether to show the sensors on the map." + }, "description": "Configure a public weather sensor for an area.", "title": "Netatmo public weather sensor" }, @@ -189,6 +198,10 @@ "new_area": "Area name", "weather_areas": "Weather areas" }, + "data_description": { + "new_area": "Name of a new weather area to add.", + "weather_areas": "Configured weather areas to edit or remove." + }, "description": "Configure public weather sensors.", "title": "[%key:component::netatmo::options::step::public_weather::title%]" } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 077d55d1ea6d..6683e4be8d97 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -621,7 +621,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nasweb", "neato", "nederlandse_spoorwegen", - "netatmo", "netdata", "netgear", "netgear_lte", @@ -1574,7 +1573,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "nederlandse_spoorwegen", "nest", "ness_alarm", - "netatmo", "netdata", "netgear", "netgear_lte", From ef9ef35d7d9998256b71d75efe1f750c36cea91a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:23:53 +0200 Subject: [PATCH 515/707] Migrate UniFi Protect camera enumeration to the public API (#176267) --- .../components/unifiprotect/camera.py | 404 +++++++--- homeassistant/components/unifiprotect/data.py | 106 ++- .../components/unifiprotect/strings.json | 3 + .../components/unifiprotect/utils.py | 14 +- tests/components/unifiprotect/conftest.py | 65 +- tests/components/unifiprotect/test_camera.py | 750 +++++++++++++++++- tests/components/unifiprotect/test_number.py | 8 + tests/components/unifiprotect/test_select.py | 8 + tests/components/unifiprotect/utils.py | 29 + 9 files changed, 1207 insertions(+), 180 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index b923572931f3..c735bf00963d 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -1,19 +1,30 @@ """Support for Ubiquiti's UniFi Protect NVR.""" +from collections.abc import Iterable import logging -from typing import override +from typing import cast, override from uiprotect.data import ( Camera as UFPCamera, - CameraChannel, + ChannelQuality, + DeviceState, ModelType, ProtectAdoptableDeviceModel, + PublicDeviceModel, StateType, + channel_id_for_quality, ) +from uiprotect.data.public_devices import PublicCamera from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + device_registry as dr, + entity_platform, + issue_registry as ir, +) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity @@ -24,6 +35,7 @@ from .const import ( ATTR_FPS, ATTR_HEIGHT, ATTR_WIDTH, + DEFAULT_BRAND, DOMAIN, ) from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -33,22 +45,31 @@ from .utils import async_ufp_instance_command, get_camera_base_name _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +# Main (non-package) RTSPS quality tiers, in default-preference order. +_MAIN_QUALITIES = ( + ChannelQuality.HIGH, + ChannelQuality.MEDIUM, + ChannelQuality.LOW, +) + @callback def _create_rtsp_repair( - hass: HomeAssistant, entry: UFPConfigEntry, camera: UFPCamera + hass: HomeAssistant, entry: UFPConfigEntry, public: PublicCamera ) -> None: + # Keyed on the public camera: the fix flow verifies and creates the stream + # through the public API, so it works without a private session too. ir.async_create_issue( hass, DOMAIN, - f"rtsp_disabled_{camera.id}", + f"rtsp_disabled_{public.id}", is_fixable=True, is_persistent=False, learn_more_url="https://www.home-assistant.io/integrations/unifiprotect/#camera-streams", severity=IssueSeverity.WARNING, translation_key="rtsp_disabled", - translation_placeholders={"camera": camera.display_name}, - data={"entry_id": entry.entry_id, "camera_id": camera.id}, + translation_placeholders={"camera": public.display_name}, + data={"entry_id": entry.entry_id, "camera_id": public.id}, ) @@ -58,74 +79,115 @@ def _async_camera_entities( entry: UFPConfigEntry, data: ProtectData, ufp_device: UFPCamera | None = None, + public_device: PublicCamera | None = None, ) -> list[ProtectDeviceEntity]: - """Create camera entities with stream URLs sourced from the public API. + """Create camera entities, enumerated public-master from ``PublicCamera``. - One entity per *active* RTSPS quality (the first is enabled by default). The - package channel is a snapshot-first view and is always exposed (disabled by - default), streaming only when its quality is active. When no main quality is - active the first non-package channel is still created so snapshots work, and - a repair offers to activate its stream. RTSPS URLs come from the public API - (the authoritative per-camera host, so stacked consoles resolve correctly) - with SRTP stripped for go2rtc. + Stream URLs come from the public API because it carries the authoritative + per-camera host (stacked consoles resolve correctly), SRTP-stripped for + go2rtc. """ disable_stream = data.disable_stream entities: list[ProtectDeviceEntity] = [] - cameras = data.get_cameras() if ufp_device is None else [ufp_device] - for camera in cameras: - if not camera.channels: - if ufp_device is None: - # only warn on startup - _LOGGER.warning( - "Camera does not have any channels: %s (id: %s)", + + # Public-master enumeration: iterate the public camera list; the private + # camera is paired by shared id (fill) and is None in public-only mode. + pairs: Iterable[tuple[PublicCamera | None, UFPCamera | None]] + if public_device is not None: + private = ( + None + if data.api.is_public_only + else data.api.bootstrap.cameras.get(public_device.id) + ) + # mirror the startup enumeration's adopted filter + if private is not None and not private.is_adopted_by_us: + return entities + pairs = [(public_device, private)] + elif ufp_device is None: + pairs = data.get_public_cameras() + else: + adopted = data.async_get_public_device(ufp_device) + pairs = [(adopted if isinstance(adopted, PublicCamera) else None, ufp_device)] + + for public, camera in pairs: + # A just-adopted camera not yet mirrored into the public bootstrap is + # deferred and picked up when enumeration re-runs. + if public is None: + if camera is not None: + _LOGGER.debug( + "Deferring camera %s until its public mirror arrives", camera.display_name, - camera.id, ) - data.async_add_pending_camera_id(camera.id) + data.async_add_pending_camera_id(camera.id) continue - streams = data.get_rtsps_streams(camera.id) - active = set(streams.get_active_stream_qualities()) if streams else set() - issue_id = f"rtsp_disabled_{camera.id}" + # Hybrid: a camera not yet in the private bootstrap (adopt race) is + # skipped rather than built private-less — the adopt dispatch creates + # it with its private fill, which would otherwise collide on unique_id. + if camera is None and not data.api.is_public_only: + _LOGGER.debug( + "Deferring camera %s until its private object is adopted", + public.display_name, + ) + continue + streams = data.get_rtsps_streams(public.id) + issue_id = f"rtsp_disabled_{public.id}" + tiers = public.hardware_stream_qualities() + main_qualities = [q for q in _MAIN_QUALITIES if q in tiers] + has_package = ChannelQuality.PACKAGE in tiers + if not main_qualities: + # The library guarantees the three main tiers; a camera without any + # is a broken contract — surface it loudly, but do not let one + # camera abort enumeration for the rest. + _LOGGER.warning( + "Camera %s reports no main stream tiers (%s); skipping", + public.display_name, + tiers, + ) + continue + + # Active stream tiers come from the public ``rtsps_streams`` object. + active = set(streams.get_active_stream_qualities()) if streams else set() has_stream = False - package_channel: CameraChannel | None = None - for channel in camera.channels: - if channel.is_package: - package_channel = channel - continue - if channel.rtsps_quality in active: + for quality in main_qualities: + if quality in active: entities.append( - ProtectCamera(data, camera, channel, not has_stream, disable_stream) + ProtectCamera( + data, public, camera, quality, not has_stream, disable_stream + ) ) has_stream = True # the package channel is a snapshot-first view (very low FPS); always # expose it (disabled by default), streaming only when its quality is active - if package_channel is not None: + if has_package: entities.append( - ProtectCamera(data, camera, package_channel, False, disable_stream) + ProtectCamera( + data, public, camera, ChannelQuality.PACKAGE, False, disable_stream + ) ) if has_stream: ir.async_delete_issue(hass, DOMAIN, issue_id) continue - # no active main stream: expose the first non-package channel for snapshots - fallback = next((c for c in camera.channels if not c.is_package), None) - if fallback is None: - continue - entities.append(ProtectCamera(data, camera, fallback, True, disable_stream)) + # no active main stream: expose the first main tier for snapshots + entities.append( + ProtectCamera(data, public, camera, main_qualities[0], True, disable_stream) + ) # no repair when the stream can't be enabled anyway: a disconnected - # camera is streamless because it is offline, not because it needs one + # camera is streamless because it is offline, not because it needs one. + # The fix flow runs entirely on the public API, so public-only cameras + # get the repair too; third-party is only knowable with a private fill. if ( disable_stream - or camera.is_third_party_camera - or camera.state is not StateType.CONNECTED + or public.state is not DeviceState.CONNECTED + or (camera is not None and camera.is_third_party_camera) ): ir.async_delete_issue(hass, DOMAIN, issue_id) else: - _create_rtsp_repair(hass, entry, camera) + _create_rtsp_repair(hass, entry, public) return entities @@ -136,13 +198,22 @@ async def async_setup_entry( ) -> None: """Discover cameras on a UniFi Protect NVR.""" data = entry.runtime_data + platform = entity_platform.async_get_current_platform() @callback - def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - # AiPort inherits from Camera but should not create camera entities - if not isinstance(device, UFPCamera) or device.model is ModelType.AIPORT: - return - async_add_entities(_async_camera_entities(hass, entry, data, ufp_device=device)) + def _add_new_device(device: ProtectAdoptableDeviceModel | PublicCamera) -> None: + if isinstance(device, PublicCamera): + entities = _async_camera_entities(hass, entry, data, public_device=device) + else: + # AiPort inherits from Camera but should not create camera entities + if not isinstance(device, UFPCamera) or device.model is ModelType.AIPORT: + return + entities = _async_camera_entities(hass, entry, data, ufp_device=device) + # A re-enumeration (deferred mirror, RTSPS prime) overlaps entities + # that already exist; the platform errors on live duplicates rather + # than deduplicating, so add only the missing ones. + live = {e.unique_id for e in platform.entities.values()} + async_add_entities([e for e in entities if e.unique_id not in live]) data.async_subscribe_adopt(_add_new_device) entry.async_on_unload( @@ -164,24 +235,38 @@ class ProtectCamera(ProtectDeviceEntity, Camera): "_attr_available", "_attr_is_recording", "_attr_motion_detection_enabled", + # flips with the stream source (an RTSPS prime can be the only change) + "_attr_supported_features", ) def __init__( self, data: ProtectData, - camera: UFPCamera, - channel: CameraChannel, + public: PublicCamera, + private: UFPCamera | None, + quality: ChannelQuality, is_default: bool, disable_stream: bool, ) -> None: - """Initialize an UniFi camera.""" - self.channel = channel + """Initialize an UniFi camera. + + The public camera is the master; the private camera fills gaps the + public API does not cover and is ``None`` in public-only mode. + """ + self._public = public + self._public_missing = False + self._private = private + self._quality = quality + self._is_package = quality is ChannelQuality.PACKAGE + self._channel_id = channel_id_for_quality(quality) self._disable_stream = disable_stream self._last_image: bytes | None = None - super().__init__(data, camera) - self._attr_unique_id = f"{self.device.mac}_{channel.id}" - self._attr_name = get_camera_base_name(channel) - # only the default (first active) channel is enabled by default + # The base tracks the private device in hybrid (unchanged behaviour) and + # the public device in public-only, so it always has a mac to key on. + super().__init__(data, cast(ProtectDeviceType, private or public)) + self._attr_unique_id = f"{self.device.mac}_{self._channel_id}" + self._attr_name = get_camera_base_name(quality) + # only the default (first active) quality channel is enabled by default self._attr_entity_registry_enabled_default = is_default # Set the stream source before finishing the init # because async_added_to_hass is too late and camera @@ -192,21 +277,17 @@ class ProtectCamera(ProtectDeviceEntity, Camera): @callback def _async_set_stream_source(self) -> None: """Set the public-API RTSPS stream URL (SRTP stripped for go2rtc).""" - quality = self.channel.rtsps_quality - streams = self.data.get_rtsps_streams(self.device.id) - if self._disable_stream or quality is None or streams is None: + quality = self._quality + streams = self.data.get_rtsps_streams(self._public.id) + if self._disable_stream or streams is None: source = None - if ( - streams is None - and not self._disable_stream - and not self.channel.is_package - ): + if streams is None and not self._disable_stream and not self._is_package: # online camera unexpectedly absent from the public bootstrap; # log so this is distinguishable from an intentionally off stream _LOGGER.debug( "No public RTSPS data for camera %s (%s); using snapshots", - self.device.display_name, - self.device.id, + self._public.name, + self._public.id, ) else: source = streams.get_stream_url(quality, srtp=False) @@ -215,43 +296,162 @@ class ProtectCamera(ProtectDeviceEntity, Camera): @callback @override - def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: - super()._async_update_device_from_protect(device) - updated_device = self.device - channel = updated_device.channels[self.channel.id] - self.channel = channel - motion_enabled = updated_device.recording_settings.enable_motion_detection - self._attr_motion_detection_enabled = ( - motion_enabled if motion_enabled is not None else True + def _async_set_device_info(self) -> None: + if self._private is not None: + super()._async_set_device_info() + return + # public-only: no market_name/firmware_version/protect_url, and + # ``type`` only on newer firmware, so device identity is limited. The + # NVR link is omitted — an API-key-only client has no private + # bootstrap to read the NVR mac from, and resolving it publicly is + # async; the public-only config mode wires it at setup instead. + public = self._public + self._attr_device_info = DeviceInfo( + name=public.display_name, + model=public.type, + manufacturer=DEFAULT_BRAND, + connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, ) - state_type_is_connected = updated_device.state is StateType.CONNECTED - self._attr_is_recording = ( - state_type_is_connected and updated_device.is_recording - ) - is_connected = self.data.last_update_success and state_type_is_connected - # some cameras have detachable lens that could cause the camera to be offline - self._attr_available = is_connected and updated_device.is_video_ready + @callback + @override + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + if self._private is not None: + super()._async_update_device_from_protect(device) + updated_device = self.device + # A poll/resync can replace the bootstrap objects; follow them so + # commands and reads never act on a detached model. + self._private = updated_device + if isinstance( + public := self.data.async_get_public_device(updated_device), + PublicCamera, + ): + self._public = public + else: + # keep the last object for identity, but log so a vanished + # public mirror is observable rather than a silent no-op + _LOGGER.debug( + "Camera %s has no public mirror; keeping the last known one", + updated_device.display_name, + ) + channel_id = self._channel_id + channel = ( + updated_device.channels[channel_id] + if channel_id is not None and channel_id < len(updated_device.channels) + else None + ) + if channel is None: + # A tier without its private channel blanks the diagnostics; + # log so a camera reconfiguration (or a quality that maps to no + # channel) is distinguishable from a bug. + _LOGGER.debug( + "Camera %s has no private channel %s; diagnostic attributes" + " unavailable", + updated_device.display_name, + channel_id, + ) + motion_enabled = updated_device.recording_settings.enable_motion_detection + self._attr_motion_detection_enabled = ( + motion_enabled if motion_enabled is not None else True + ) + state_type_is_connected = updated_device.state is StateType.CONNECTED + self._attr_is_recording = ( + state_type_is_connected and updated_device.is_recording + ) + is_connected = self.data.last_update_success and state_type_is_connected + # some cameras have detachable lens that could make them offline + self._attr_available = is_connected and updated_device.is_video_ready + + self._async_set_stream_source() + self._attr_extra_state_attributes = { + ATTR_WIDTH: channel.width if channel else None, + ATTR_HEIGHT: channel.height if channel else None, + ATTR_FPS: channel.fps if channel else None, + ATTR_BITRATE: channel.bitrate if channel else None, + ATTR_CHANNEL_ID: channel_id, + } + return + + # public-only: recording/motion state and the per-stream diagnostics + # have no public equivalent and degrade; availability tracks the public + # devices websocket health and the public camera state. + public = self._public + self._attr_motion_detection_enabled = False + self._attr_is_recording = False + self._attr_available = ( + self.data.last_public_update_success + and not self._public_missing + and public.state is DeviceState.CONNECTED + ) self._async_set_stream_source() self._attr_extra_state_attributes = { - ATTR_WIDTH: channel.width, - ATTR_HEIGHT: channel.height, - ATTR_FPS: channel.fps, - ATTR_BITRATE: channel.bitrate, - ATTR_CHANNEL_ID: channel.id, + ATTR_WIDTH: None, + ATTR_HEIGHT: None, + ATTR_FPS: None, + ATTR_BITRATE: None, + ATTR_CHANNEL_ID: self._channel_id, } + @callback + def _async_public_camera_updated(self, obj: PublicDeviceModel | None) -> None: + """Handle a public devices websocket update for this camera. + + ``obj`` is the refreshed public object, or ``None`` for a websocket + state change or an unmergeable frame, in which case it is re-read from + the public bootstrap. A camera missing from the bootstrap on re-read + has been removed and reads as unavailable until it reappears. + """ + if obj is None: + obj = self.data.async_get_public_device(self._public) + if isinstance(obj, PublicCamera): + self._public = obj + self._public_missing = False + else: + self._public_missing = True + device = ( + self._private + if self._private is not None + else cast(ProtectDeviceType, self._public) + ) + self._async_updated_event(device) + + @override + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + # The stream URLs live on the public camera and change outside the + # private websocket (a background RTSPS prime announces itself on the + # public channel), so every camera tracks its public mirror; in + # public-only mode this is also the only state source. + self.async_on_remove( + self.data.async_subscribe_public( + self._public.mac, self._async_public_camera_updated + ) + ) + # A public update or delete can land between entity construction and + # this subscription; re-read so the entity does not start stale. + self._async_public_camera_updated(None) + @override async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: - """Return the Camera Image.""" - # Without a stream the camera is rendered by rapidly polling snapshots; - # request low quality then to avoid hammering the console with large - # images. width/height are unused (the public endpoint has no resize). - high_quality = None if self._stream_source else False - self._last_image = await self.device.get_public_api_snapshot( - high_quality=high_quality, package=self.channel.is_package + """Return the Camera Image. + + While snapshot-polling (no stream) request low quality to avoid + hammering the console. width/height are unused (the public endpoint + has no resize). + """ + # Inlines the library's device-level default (support_full_hd_snapshot + # when streaming, low otherwise) since public-only has no private + # device object; the resolved value is unchanged. + high_quality = bool( + self._stream_source and self._public.feature_flags.support_full_hd_snapshot + ) + self._last_image = await self.data.api.get_public_api_camera_snapshot( + camera_id=self._public.id, + high_quality=high_quality, + package=self._is_package, ) return self._last_image @@ -264,10 +464,20 @@ class ProtectCamera(ProtectDeviceEntity, Camera): @override async def async_enable_motion_detection(self) -> None: """Call the job and enable motion detection.""" - await self.device.set_motion_detection(True) + await self._async_set_motion_detection(True) @async_ufp_instance_command @override async def async_disable_motion_detection(self) -> None: """Call the job and disable motion detection.""" - await self.device.set_motion_detection(False) + await self._async_set_motion_detection(False) + + async def _async_set_motion_detection(self, enabled: bool) -> None: + # the public API has no motion-detection setter; without a private + # session the command cannot be sent and must not report success. + if (private := self._private) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="motion_detection_public_only", + ) + await private.set_motion_detection(enabled) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index cdc7f7ed891e..7c68fd4b2809 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -8,6 +8,7 @@ from functools import partial import logging from typing import TYPE_CHECKING, Any, cast +from aiohttp.client_exceptions import ServerDisconnectedError from uiprotect import EventChange, ProtectApiClient, ProtectEvent from uiprotect.api import RTSPSStreams from uiprotect.data import ( @@ -19,8 +20,10 @@ from uiprotect.data import ( ProtectAdoptableDeviceModel, PTZPatrol, PublicDeviceModel, + WSAction, WSSubscriptionMessage, ) +from uiprotect.data.public_devices import PublicCamera from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.utils import log_event from uiprotect.websocket import WebsocketState @@ -152,6 +155,37 @@ class ProtectData: Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) ) + def get_public_cameras( + self, + ) -> Generator[tuple[PublicCamera | None, Camera | None]]: + """Iterate cameras public-master with private-fill. + + The public bootstrap is the master list; the matching private camera is + paired by shared id when present (hybrid) and ``None`` in public-only + mode. An adopted private camera not (yet) mirrored into the public + bootstrap is yielded as ``(None, private)`` so the caller can defer it. + Adopted-filtering mirrors ``get_cameras`` whenever a private object is + available. + """ + api = self.api + if not api.has_public_bootstrap: + return + # An API-key-only client never initializes the private bootstrap; + # accessing it would raise. + private_cameras: dict[str, Camera] = ( + {} if api.is_public_only else api.bootstrap.cameras + ) + public_cameras = api.public_bootstrap.cameras + for camera_id, public in public_cameras.items(): + private = private_cameras.get(camera_id) + if private is not None and not private.is_adopted_by_us: + continue + yield public, private + for camera_id, private in private_cameras.items(): + if camera_id in public_cameras or not private.is_adopted_by_us: + continue + yield None, private + async def async_load_ptz_patrols(self) -> None: """Load PTZ patrols for all PTZ cameras.""" await asyncio.gather( @@ -232,11 +266,44 @@ class ProtectData: self._async_signal_public_update(old_obj.mac, None) return if new_obj.model is ModelType.NVR: - self._async_signal_device_update(self.api.bootstrap.nvr) + # An API-key-only client has no private NVR (reading it would raise). + if not self.api.is_public_only: + self._async_signal_device_update(self.api.bootstrap.nvr) return if isinstance(new_obj, PublicDeviceModel): + if new_obj.model is ModelType.CAMERA: + self._async_reenumerate_camera_on_public_change(new_obj, message) self._async_signal_public_update(new_obj.mac, new_obj) + @callback + def _async_reenumerate_camera_on_public_change( + self, new_obj: PublicDeviceModel, message: WSSubscriptionMessage + ) -> None: + """Re-run camera enumeration when a public frame can add entities. + + Three cases dispatch the public camera to the channels signal: + + - A camera deferred at enumeration because its public mirror had not + arrived yet (the private channels-update path cannot be relied on to + fire again). + - A camera whose RTSPS streams the library primes in the background + after it comes online or is added, announced by an ``rtsps_streams`` + change: the quality tiers that just became active still need their + entities. + - In public-only mode, a newly added camera — there is no private + adopt path that could discover it. + + The platform adds only entities that do not exist yet, so overlapping + re-enumerations are safe. + """ + if new_obj.id in self._pending_camera_ids: + self._pending_camera_ids.remove(new_obj.id) + elif "rtsps_streams" not in message.changed_data and not ( + self.api.is_public_only and message.action is WSAction.ADD + ): + return + async_dispatcher_send(self._hass, self.channels_signal, new_obj) + @callback def _async_process_public_event( self, event: ProtectEvent, change: EventChange @@ -274,6 +341,37 @@ class ProtectData: return self.last_public_update_success = success self._async_process_public_updates() + if success: + # The library resyncs its public bootstrap on reconnect, but the + # resync applies silently and races this callback, so the re-read + # above may see the pre-disconnect cache. Refresh again behind a + # guaranteed-fresh snapshot (``update_public`` is serialized) so a + # change from the disconnect gap cannot stay stale. + self._entry.async_create_background_task( + self._hass, + self._async_resignal_after_public_resync(), + "unifiprotect public reconnect refresh", + ) + + async def _async_resignal_after_public_resync(self) -> None: + """Re-signal public entities once a fresh public snapshot is applied.""" + try: + await self.api.update_public() + except NotAuthorized: + # A revoked API key cannot self-recover. + self._entry.async_start_reauth(self._hass) + return + except (TimeoutError, ClientError, ServerDisconnectedError) as err: + # Transport errors retry on the next reconnect. + _LOGGER.debug("Public refresh after reconnect failed: %s", err) + return + self._async_process_public_updates() + # Existing subscriptions are refreshed above, but a camera that + # appeared (or gained streams) during the gap still needs its + # entities; the platform adds only the missing ones. + if self.api.has_public_bootstrap: + for public in list(self.api.public_bootstrap.cameras.values()): + async_dispatcher_send(self._hass, self.channels_signal, public) @callback def _async_process_public_updates(self) -> None: @@ -282,7 +380,9 @@ class ProtectData: if not api.has_public_bootstrap: return # The NVR alarm panel reads the public arm_mode, so refresh it too. - self._async_signal_device_update(api.bootstrap.nvr) + # An API-key-only client has no private NVR (reading it would raise). + if not api.is_public_only: + self._async_signal_device_update(api.bootstrap.nvr) # Subscribers recompute from the public bootstrap on ``None``. for subscriptions in self._public_subscriptions.values(): for update_callback in subscriptions: @@ -529,7 +629,7 @@ class ProtectData: @callback def async_get_public_device( - self, device: ProtectDeviceType + self, device: ProtectDeviceType | PublicDeviceModel ) -> PublicDeviceModel | None: """Return the public-API object matching a device, if available.""" api = self.api diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 80bbb142cdbf..eb7b65358f73 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -692,6 +692,9 @@ "global_alarm_manager": { "message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally." }, + "motion_detection_public_only": { + "message": "Motion detection cannot be changed over the public API; configure it in the UniFi Protect app" + }, "no_users_found": { "message": "No users found, please check Protect permissions" }, diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 7a6deddcc4b5..933c0f9b6e8d 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -11,7 +11,7 @@ from aiohttp import CookieJar from uiprotect import ProtectApiClient from uiprotect.data import ( Bootstrap, - CameraChannel, + ChannelQuality, Light, LightModeEnableType, LightModeType, @@ -134,14 +134,12 @@ def async_create_api_client( @callback -def get_camera_base_name(channel: CameraChannel) -> str: - """Get base name for cameras channel.""" +def get_camera_base_name(quality: ChannelQuality) -> str: + """Get base name for a camera's RTSPS quality channel.""" - camera_name = channel.name - if channel.name != "Package Camera": - camera_name = f"{channel.name} resolution channel" - - return camera_name + if quality is ChannelQuality.PACKAGE: + return "Package Camera" + return f"{quality.value.title()} resolution channel" def async_ufp_instance_command[_EntityT, **_P]( diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index cf212ed7357e..986818c6cad2 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -6,7 +6,6 @@ from functools import partial from ipaddress import IPv4Address from pathlib import Path from tempfile import gettempdir -from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -22,6 +21,8 @@ from uiprotect.data import ( CloudAccount, Light, Liveview, + ModelType, + ProtectModelWithId, Sensor, SmartDetectObjectType, StateType, @@ -45,26 +46,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from . import _patch_discovery -from .utils import MockUFPFixture +from .utils import MockUFPFixture, make_public_camera, public_rtsps_for from tests.common import MockConfigEntry, load_json_object_fixture - -def _public_rtsps_for(camera: Any) -> RTSPSStreams | None: - """Build a camera's primed RTSPS streams from its RTSP-enabled channels. - - Mirrors what the library writes onto ``PublicCamera.rtsps_streams`` during - ``update_public()`` — only RTSP-enabled channels carry an active URL, and a - camera with none is left streamless (``None``). - """ - urls = { - channel.rtsps_quality: channel.rtsps_url - for channel in camera.channels - if channel.is_rtsp_enabled and channel.rtsps_quality is not None - } - return RTSPSStreams(**urls) if urls else None - - MAC_ADDR = "aa:bb:cc:dd:ee:ff" # Common test data constants @@ -182,6 +167,7 @@ def mock_ufp_client(bootstrap: Bootstrap): client.update_public = AsyncMock() client.async_disconnect_ws = AsyncMock() client.has_public_bootstrap = True + client.is_public_only = False # The library owns RTSPS streams on ``PublicCamera.rtsps_streams`` and primes # them in ``update_public()``; the integration reads them synchronously. Start @@ -193,15 +179,25 @@ def mock_ufp_client(bootstrap: Bootstrap): client.public_bootstrap.sirens = {} client.public_bootstrap.arm_profiles = {} client.public_bootstrap.arm_mode = None - # No paired public device by default; tests opt in via setup_public_* helpers. - client.public_bootstrap.get = Mock(return_value=None) + + # Cameras resolve to their primed public model (see ``update_public`` in + # ``mock_entry``); other device types opt in via the ``setup_public_*`` + # helpers, so they default to no paired public object. + def _public_bootstrap_get( + model: ModelType, obj_id: str + ) -> ProtectModelWithId | None: + if model is ModelType.CAMERA: + return client.public_bootstrap.cameras.get(obj_id) + return None + + client.public_bootstrap.get = Mock(side_effect=_public_bootstrap_get) async def get_camera_rtsps_streams( camera_id: str, *args: Any, **kwargs: Any ) -> RTSPSStreams | None: """Fetch a camera's RTSPS streams (used by the repair flow).""" camera = client.bootstrap.cameras.get(camera_id) - return _public_rtsps_for(camera) if camera is not None else None + return public_rtsps_for(camera) if camera is not None else None client.get_camera_rtsps_streams = AsyncMock(side_effect=get_camera_rtsps_streams) client.create_camera_rtsps_streams = AsyncMock(return_value=None) @@ -265,23 +261,20 @@ def mock_entry( ufp_client.subscribe_devices_websocket_state = subscribe_devices_websocket_state async def update_public() -> Any: - # Mirror the library prime: populate PublicCamera.rtsps_streams for - # every camera from the private bootstrap (connected cameras only, so - # a disconnected camera stays streamless), keyed by id. + # Mirror the library prime: build each camera's public model from the + # private bootstrap and attach its RTSPS streams (connected cameras + # only, so a disconnected camera stays streamless), keyed by id. pb = ufp_client.public_bootstrap - pb.cameras = { - camera.id: SimpleNamespace( - id=camera.id, - name=camera.display_name, - state=camera.state, - rtsps_streams=( - _public_rtsps_for(camera) - if camera.state is StateType.CONNECTED - else None - ), + cameras: dict[str, Any] = {} + for camera in ufp_client.bootstrap.cameras.values(): + public = make_public_camera(camera) + public.rtsps_streams = ( + public_rtsps_for(camera) + if camera.state is StateType.CONNECTED + else None ) - for camera in ufp_client.bootstrap.cameras.values() - } + cameras[camera.id] = public + pb.cameras = cameras return pb ufp_client.update_public = AsyncMock(side_effect=update_public) diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 8c429e5397ae..cb058870708e 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -1,12 +1,21 @@ """Test the UniFi Protect camera platform.""" -from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch +from aiohttp.client_exceptions import ServerDisconnectedError import pytest -from uiprotect.data import AiPort, Camera as ProtectCamera, StateType +from uiprotect.data import ( + AiPort, + Camera as ProtectCamera, + ChannelQuality, + DeviceState, + ModelType, + StateType, + WSAction, +) from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.websocket import WebsocketState from homeassistant.components.camera import ( CameraEntityFeature, @@ -16,9 +25,14 @@ from homeassistant.components.camera import ( from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN from homeassistant.components.unifiprotect.utils import get_camera_base_name from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from . import patch_ufp_method from .utils import ( @@ -27,14 +41,18 @@ from .utils import ( assert_entity_counts, enable_entity, init_entry, + make_public_camera, + public_device_ws_message, + public_rtsps_for, remove_entities, ) def _channel_entity_id(camera_obj: ProtectCamera, channel_id: int) -> str: """Return the entity_id for a camera channel.""" - channel = camera_obj.channels[channel_id] - base_name = get_camera_base_name(channel) + quality = camera_obj.channels[channel_id].rtsps_quality + assert quality is not None + base_name = get_camera_base_name(quality) return f"camera.{camera_obj.name}_{base_name}".replace(" ", "_").lower() @@ -242,36 +260,29 @@ async def test_package_camera_without_stream( assert ufp.api.get_public_api_camera_snapshot.call_args.kwargs["package"] is True -async def test_package_only_camera( - hass: HomeAssistant, - ufp: MockUFPFixture, - camera: ProtectCamera, - issue_registry: ir.IssueRegistry, -) -> None: - """A camera with only a package channel still exposes a snapshot entity.""" - package = camera.channels[0].model_copy() - package.id = 3 - package.fps = 2 - package.is_rtsp_enabled = False - camera.channels = [package] - - await init_entry(hass, ufp, [camera]) - - # only the package snapshot entity (disabled by default); no main fallback, no repair - assert_entity_counts(hass, Platform.CAMERA, 1, 0) - _assert_entity(hass, camera, 0, enabled=False) - assert issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera.id}") is None - - -async def test_no_channels( +async def test_camera_not_in_public_bootstrap( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: - """A camera without channels yet creates no entities.""" - camera.channels = [] + """A camera not yet mirrored into the public bootstrap is deferred.""" + + async def _prime_without_camera() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 0, 0) + # the public mirror arriving on the devices websocket creates the entity + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + ufp.api.public_bootstrap.cameras = {camera.id: public} + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + async def test_streams_unavailable( hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera @@ -280,11 +291,9 @@ async def test_streams_unavailable( async def _prime_streamless() -> Any: pb = ufp.api.public_bootstrap - pb.cameras = { - camera_all.id: SimpleNamespace( - id=camera_all.id, state=camera_all.state, rtsps_streams=None - ) - } + public = make_public_camera(camera_all) + public.rtsps_streams = None + pb.cameras = {camera_all.id: public} return pb ufp.api.update_public = AsyncMock(side_effect=_prime_streamless) @@ -383,6 +392,263 @@ async def test_aiport_no_camera_entities( assert_entity_counts(hass, Platform.CAMERA, 0, 0) +async def test_public_only_camera( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A public-only camera builds a working entity with degraded diagnostics.""" + # This camera is intentionally kept out of the private bootstrap; wire the + # channel api so its public RTSPS URLs resolve, as add_device would. + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + # No private cameras in the bootstrap: the public object is the only source. + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + + entity_id = _channel_entity_id(camera, 0) + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + # diagnostics have no public equivalent and degrade to None + assert state.attributes["fps"] is None + + # device identity degrades to name-only; the NVR link is omitted (resolving + # the NVR identity publicly is wired with the config-mode setup) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, public.mac)} + ) + assert device is not None + assert device.via_device_id is None + assert device.name == camera.display_name + assert device.model == camera.type + + assert ( + await async_get_stream_source(hass, entity_id) + == camera.channels[0].rtsps_no_srtp_url + ) + + ufp.api.get_public_api_camera_snapshot = AsyncMock() + await async_get_image(hass, entity_id) + ufp.api.get_public_api_camera_snapshot.assert_called_once() + + # a frame without a merged object re-reads the public bootstrap and stays up + none_msg = Mock() + none_msg.changed_data = {} + none_msg.new_obj = None + none_msg.old_obj = public + ufp.devices_ws_subscription(none_msg) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # a public devices websocket update drives availability + public.state = DeviceState.DISCONNECTED + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_adopt_before_public_bootstrap( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A camera adopted before the public bootstrap mirrors it is deferred.""" + + async def _prime_empty() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_empty) + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + # adopt the camera while it is still absent from the public bootstrap + camera._api = ufp.api + await adopt_devices(hass, ufp, [camera], fully_adopt=True) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + # the public mirror arriving on the devices websocket creates the entity + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + ufp.api.public_bootstrap.cameras = {camera.id: public} + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + + +async def test_public_only_camera_removed( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A public-only camera removed from the public bootstrap goes unavailable.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # on a delete the library has already dropped the object; the re-read + # comes up empty and the entity goes unavailable + ufp.api.public_bootstrap.cameras = {} + delete_msg = Mock() + delete_msg.changed_data = {} + delete_msg.new_obj = None + delete_msg.old_obj = public + ufp.devices_ws_subscription(delete_msg) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # the camera reappearing on the websocket recovers it + ufp.api.public_bootstrap.cameras = {camera.id: public} + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +async def test_public_only_camera_ws_state_availability( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """Public devices websocket state drives a public-only camera without private reads.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # tripwire: from here on nothing may read the private bootstrap + ufp.api.bootstrap = None + + # a public NVR frame has no private NVR to signal and is ignored + nvr_msg = Mock() + nvr_msg.changed_data = {} + nvr_msg.old_obj = None + nvr_msg.new_obj = Mock(model=ModelType.NVR) + ufp.devices_ws_subscription(nvr_msg) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # a websocket drop marks the camera unavailable, a reconnect recovers it + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +async def test_camera_motion_detection_uses_replaced_device( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """Motion commands act on the refreshed device after a bootstrap replacement.""" + await init_entry(hass, ufp, [camera]) + entity_id = _channel_entity_id(camera, 0) + + new_camera = camera.model_copy() + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + with patch_ufp_method( + new_camera, "set_motion_detection", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + "camera", + "enable_motion_detection", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_method.assert_called_once_with(True) + + +@pytest.mark.parametrize( + "service", ["enable_motion_detection", "disable_motion_detection"] +) +async def test_public_only_camera_motion_detection_raises( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera, service: str +) -> None: + """Motion detection cannot be changed without a private session.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + + with pytest.raises(HomeAssistantError, match="public API"): + await hass.services.async_call( + "camera", + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_hybrid_public_camera_without_private_deferred( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """Hybrid: a public camera without its private twin is left to the adopt flow.""" + public = make_public_camera(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + + # Not public-only mode (conftest default): the entity must not be built + # private-less; the later adopt dispatch creates it with its private fill. + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + async def test_snapshot_low_quality_without_stream( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: @@ -399,3 +665,415 @@ async def test_snapshot_low_quality_without_stream( assert ( ufp.api.get_public_api_camera_snapshot.call_args.kwargs["high_quality"] is False ) + + +async def test_private_enumeration_upgrade_keeps_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera_all: ProtectCamera, +) -> None: + """Old private-enumeration registry entries survive the public enumeration. + + Same unique_ids, entity_ids, and enabled split — including across a reload. + """ + seeded: dict[str, str] = {} + for channel_id, disabled_by in ( + (0, None), + (1, er.RegistryEntryDisabler.INTEGRATION), + (2, er.RegistryEntryDisabler.INTEGRATION), + ): + entry = entity_registry.async_get_or_create( + Platform.CAMERA, + DOMAIN, + f"{camera_all.mac}_{channel_id}", + config_entry=ufp.entry, + suggested_object_id=f"my_renamed_cam_{channel_id}", + disabled_by=disabled_by, + ) + seeded[entry.unique_id] = entry.entity_id + + await init_entry(hass, ufp, [camera_all], regenerate_ids=False) + + # Same totals as a fresh setup: nothing duplicated, nothing orphaned. + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + for unique_id, entity_id in seeded.items(): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.unique_id == unique_id + + # The customized (enabled) entity is live and streams from the public API. + high_id = seeded[f"{camera_all.mac}_0"] + assert hass.states.get(high_id) is not None + assert ( + await async_get_stream_source(hass, high_id) + == camera_all.channels[0].rtsps_no_srtp_url + ) + + await hass.config_entries.async_reload(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + for unique_id, entity_id in seeded.items(): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.unique_id == unique_id + assert hass.states.get(high_id) is not None + + +async def test_streamless_camera_reenumerated_on_rtsps_prime( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera_all: ProtectCamera, + caplog: pytest.LogCaptureFixture, +) -> None: + """A camera primed after enumeration gains its active-quality entities. + + A disconnected camera enumerates streamless (only the snapshot fallback). + When it comes online the library primes its RTSPS streams in the background + and announces the change with an ``rtsps_streams`` devices-WS frame; the + integration must re-enumerate so the now-active tiers get their entities, + without re-adding the live fallback entity, and the existing entity must + pick up its now-available stream URL. + """ + camera_all.state = StateType.DISCONNECTED + await init_entry(hass, ufp, [camera_all]) + + # Streamless: only the snapshot fallback (high) exists, without a stream. + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + high_id = _channel_entity_id(camera_all, 0) + assert await async_get_stream_source(hass, high_id) is None + assert entity_registry.async_get(_channel_entity_id(camera_all, 1)) is None + assert entity_registry.async_get(_channel_entity_id(camera_all, 2)) is None + + # The camera comes online and the library primes its streams, announced by + # an rtsps_streams change on the public devices websocket (sent twice: the + # re-enumeration must not attempt to re-add live entities). + camera_all.state = StateType.CONNECTED + public = ufp.api.public_bootstrap.cameras[camera_all.id] + public.state = DeviceState.CONNECTED + public.rtsps_streams = public_rtsps_for(camera_all) + msg = public_device_ws_message(public) + msg.changed_data = {"rtsps_streams": public.rtsps_streams} + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + # The active medium/low tiers now have entities, the existing high entity + # streams, and no duplicate-add errors were logged. + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + _assert_entity(hass, camera_all, 0, enabled=True) + _assert_entity(hass, camera_all, 1, enabled=False) + _assert_entity(hass, camera_all, 2, enabled=False) + assert ( + await async_get_stream_source(hass, high_id) + == camera_all.channels[0].rtsps_no_srtp_url + ) + assert "does not generate unique IDs" not in caplog.text + + +async def test_public_only_camera_added_after_setup( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """In public-only mode a camera added later is discovered from its frame. + + There is no private adopt path without a local user, so the public devices + websocket ``add`` frame is the only discovery signal. + """ + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + # A new camera appears on the public devices websocket. + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + ufp.api.public_bootstrap.cameras = {camera.id: public} + msg = public_device_ws_message(public) + msg.action = WSAction.ADD + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + state = hass.states.get(_channel_entity_id(camera, 0)) + assert state + assert state.state != STATE_UNAVAILABLE + + +async def test_public_only_streamless_camera_gets_repair( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + issue_registry: ir.IssueRegistry, +) -> None: + """A streamless public-only camera raises the RTSP repair. + + The fix flow verifies and creates the stream entirely through the public + API, so it works without a private session. + """ + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = None + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + assert ( + issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera.id}") is not None + ) + + +async def test_stream_capability_published_on_prime( + hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera +) -> None: + """Gaining a stream publishes the STREAM capability when nothing else changes.""" + camera_all.channels = [c.model_copy() for c in camera_all.channels] + for channel in camera_all.channels: + channel.is_rtsp_enabled = False + + await init_entry(hass, ufp, [camera_all]) + + high_id = _channel_entity_id(camera_all, 0) + state = hass.states.get(high_id) + assert state + assert state.attributes["supported_features"] == CameraEntityFeature(0) + + # The library primes the streams; availability, recording, and motion are + # unchanged, so the capability flip is the only observable difference. + camera_all.channels[0].is_rtsp_enabled = True + public = ufp.api.public_bootstrap.cameras[camera_all.id] + public.rtsps_streams = public_rtsps_for(camera_all) + msg = public_device_ws_message(public) + msg.changed_data = {"rtsps_streams": public.rtsps_streams} + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + state = hass.states.get(high_id) + assert state + assert state.attributes["supported_features"] == CameraEntityFeature.STREAM + + +async def test_camera_without_main_tiers_skipped_with_warning( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + caplog: pytest.LogCaptureFixture, +) -> None: + """A camera violating the main-tier contract is skipped loudly, not fatally.""" + for channel in camera.channels: + channel._api = ufp.api + healthy = make_public_camera(camera) + healthy.rtsps_streams = public_rtsps_for(camera) + broken = make_public_camera(camera) + broken.id = "broken-camera" + broken.mac = "FFEEDDCCBBAA" + broken.display_name = "Broken" + broken.rtsps_streams = None + broken.hardware_stream_qualities.return_value = [ChannelQuality.PACKAGE] + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: healthy, broken.id: broken} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + + # The healthy camera enumerates; the broken one is skipped with a warning + # instead of aborting the platform setup. + assert "reports no main stream tiers" in caplog.text + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + assert hass.states.get(_channel_entity_id(camera, 0)) is not None + + +async def test_public_only_camera_deleted_during_gap( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A camera deleted while the websocket was down reads as unavailable. + + The library resyncs its public bootstrap on reconnect but applies the + snapshot silently; the integration must re-read behind a fresh snapshot + rather than resurrect the entity from the pre-disconnect cache. + """ + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # The websocket drops; the camera is deleted during the gap, so the + # reconnect resync returns a snapshot without it. + async def _prime_empty() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_empty) + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + # Not resurrected from the stale cache: the fresh snapshot has no camera. + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("error", "expect_reauth"), + [ + pytest.param(NotAuthorized("revoked"), True, id="revoked_key"), + pytest.param(ServerDisconnectedError(), False, id="transport_error"), + ], +) +async def test_reconnect_refresh_failures( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + error: Exception, + expect_reauth: bool, +) -> None: + """A failed reconnect refresh starts reauth on 401 and retries on transport.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + + ufp.api.update_public = AsyncMock(side_effect=error) + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + with patch.object(ufp.entry, "async_start_reauth") as mock_reauth: + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + assert mock_reauth.called is expect_reauth + + +async def test_hybrid_camera_lost_public_mirror_logs( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + caplog: pytest.LogCaptureFixture, +) -> None: + """A vanished public mirror is observable instead of a silent no-op.""" + await init_entry(hass, ufp, [camera]) + + ufp.api.public_bootstrap.cameras = {} + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = camera + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + assert "has no public mirror" in caplog.text + + +async def test_public_only_camera_added_during_gap( + hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera +) -> None: + """A camera added while the websocket was down enumerates on reconnect. + + The resync snapshot is applied silently and no add frame ever arrives for + a camera that appeared during the gap, so the reconnect refresh must + dispatch it for enumeration itself. + """ + for channel in camera_all.channels: + channel._api = ufp.api + first = make_public_camera(camera_all) + first.rtsps_streams = public_rtsps_for(camera_all) + + async def _prime_one() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera_all.id: first} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_one) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + + # A second camera appears during the gap; the reconnect resync includes it. + second = make_public_camera(camera_all) + second.id = "gap-camera" + second.mac = "FFEEDDCCBB01" + second.name = "Gap Camera" + second.display_name = "Gap Camera" + second.rtsps_streams = first.rtsps_streams + + async def _prime_two() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera_all.id: first, second.id: second} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_two) + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + # Three tiers each for both cameras; no duplicates for the first one. + assert_entity_counts(hass, Platform.CAMERA, 6, 2) + assert hass.states.get("camera.gap_camera_high_resolution_channel") is not None + + +async def test_unadopted_camera_not_enumerated_from_public_frame( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A public frame cannot create entities for an unadopted camera.""" + camera.is_adopted = False + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + public = ufp.api.public_bootstrap.cameras[camera.id] + msg = public_device_ws_message(public) + msg.changed_data = {"rtsps_streams": public.rtsps_streams} + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + # still excluded, exactly like the startup enumeration + assert_entity_counts(hass, Platform.CAMERA, 0, 0) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index ed915a9559de..b1b8464d07f4 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -349,6 +349,14 @@ async def test_number_camera_mic_volume_unavailable_without_public( ) -> None: """The migrated mic volume number is unavailable without a public object.""" + # The default fixture mirrors every camera into the public bootstrap; + # prime it empty to model a camera the public API does not know yet. + async def _prime_empty() -> Mock: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_empty) await init_entry(hass, ufp, [camera]) _, entity_id = await ids_from_device_description( diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 2173693e3ec2..d270c1e38242 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -1,6 +1,7 @@ """Test the UniFi Protect select platform.""" from copy import copy +from typing import Any from unittest.mock import AsyncMock, Mock import pytest @@ -227,6 +228,13 @@ async def test_select_camera_hdr_mode_unavailable_without_public( ) -> None: """The migrated HDR mode select is unavailable without a public object.""" + async def _prime_without_camera() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera) + await init_entry(hass, ufp, [doorbell]) description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index eb6e47b4bbe7..218f6958dee5 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -6,9 +6,11 @@ from datetime import timedelta from unittest.mock import Mock from uiprotect import EventChange, ProtectApiClient, ProtectEvent +from uiprotect.api import RTSPSStreams from uiprotect.data import ( Bootstrap, Camera, + ChannelQuality, DeviceState, Event, EventType, @@ -229,6 +231,21 @@ async def init_entry( await hass.async_block_till_done() +def public_rtsps_for(camera: Camera) -> RTSPSStreams | None: + """Build a camera's primed RTSPS streams from its RTSP-enabled channels. + + Mirrors what the library writes onto ``PublicCamera.rtsps_streams`` during + ``update_public()`` — only RTSP-enabled channels carry an active URL, and a + camera with none is left streamless (``None``). + """ + urls = { + channel.rtsps_quality: channel.rtsps_url + for channel in camera.channels + if channel.is_rtsp_enabled and channel.rtsps_quality is not None + } + return RTSPSStreams(**urls) if urls else None + + def make_public_sensor( sensor: Sensor, *, @@ -368,6 +385,9 @@ def make_public_camera( public = Mock(spec=PublicCamera) public.id = camera.id public.mac = camera.mac + public.name = camera.name + public.display_name = camera.display_name + public.type = camera.type public.model = ModelType.CAMERA public.state = DeviceState[camera.state.name] if state is None else state public.mic_volume = camera.mic_volume if mic_volume is None else mic_volume @@ -376,6 +396,15 @@ def make_public_camera( if hdr_type is None else hdr_type ) + public.has_package_camera = camera.feature_flags.has_package_camera + public.feature_flags = Mock() + public.feature_flags.support_full_hd_snapshot = ( + camera.feature_flags.support_full_hd_snapshot + ) + qualities = [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW] + if public.has_package_camera: + qualities.append(ChannelQuality.PACKAGE) + public.hardware_stream_qualities.return_value = qualities return public From cd5e1813b9884e178cb624973430cc37e2b28228 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 13 Jul 2026 12:27:55 +0200 Subject: [PATCH 516/707] Add snapshot tests to Reolink (#176269) --- tests/components/reolink/__init__.py | 24 + tests/components/reolink/conftest.py | 48 +- .../reolink/snapshots/test_binary_sensor.ambr | 752 ++++ .../reolink/snapshots/test_button.ambr | 914 +++++ .../reolink/snapshots/test_camera.ambr | 425 +++ .../reolink/snapshots/test_light.ambr | 197 ++ .../reolink/snapshots/test_number.ambr | 3104 +++++++++++++++++ .../reolink/snapshots/test_select.ambr | 1383 ++++++++ .../reolink/snapshots/test_sensor.ambr | 762 ++++ .../reolink/snapshots/test_siren.ambr | 103 + .../reolink/snapshots/test_switch.ambr | 1301 +++++++ .../reolink/snapshots/test_update.ambr | 127 + .../components/reolink/test_binary_sensor.py | 20 +- tests/components/reolink/test_button.py | 21 +- tests/components/reolink/test_camera.py | 25 +- tests/components/reolink/test_light.py | 21 +- tests/components/reolink/test_number.py | 21 +- tests/components/reolink/test_select.py | 20 +- tests/components/reolink/test_sensor.py | 22 +- tests/components/reolink/test_siren.py | 21 +- tests/components/reolink/test_switch.py | 21 +- tests/components/reolink/test_update.py | 22 +- 22 files changed, 9342 insertions(+), 12 deletions(-) create mode 100644 tests/components/reolink/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/reolink/snapshots/test_button.ambr create mode 100644 tests/components/reolink/snapshots/test_camera.ambr create mode 100644 tests/components/reolink/snapshots/test_light.ambr create mode 100644 tests/components/reolink/snapshots/test_number.ambr create mode 100644 tests/components/reolink/snapshots/test_select.ambr create mode 100644 tests/components/reolink/snapshots/test_sensor.ambr create mode 100644 tests/components/reolink/snapshots/test_siren.ambr create mode 100644 tests/components/reolink/snapshots/test_switch.ambr create mode 100644 tests/components/reolink/snapshots/test_update.ambr diff --git a/tests/components/reolink/__init__.py b/tests/components/reolink/__init__.py index 45bcb2fab8c8..406f6f980715 100644 --- a/tests/components/reolink/__init__.py +++ b/tests/components/reolink/__init__.py @@ -1 +1,25 @@ """Tests for the Reolink integration.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Reolink integration for testing and enable all entities.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ): + if entry.disabled_by is not None: + entity_registry.async_update_entity(entry.entity_id, disabled_by=None) + + await hass.async_block_till_done() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 25677b8591e5..00a296b13d24 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.api import Chime -from reolink_aio.enums import ConnectionEnum +from reolink_aio.enums import BatteryEnum, ConnectionEnum from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -135,8 +135,45 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.broken_cmds = ["GetManualRec"] host_mock.baichuan_cmds = ["GetPtzCurPos"] host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection.return_value = False + host_mock.wifi_connection.return_value = True host_mock.wifi_signal.return_value = -45 + host_mock.ptz_pan_position.return_value = 1200 + host_mock.ptz_tilt_position.return_value = 300 + host_mock.battery_percentage.return_value = 85 + host_mock.battery_temperature.return_value = 25 + host_mock.battery_status.return_value = BatteryEnum.charging.value + host_mock.cpu_usage = 5 + host_mock.hdd_list = [0] + host_mock.hdd_type.return_value = "HDD" + host_mock.hdd_storage.return_value = 95 + host_mock.hdd_available.return_value = True + host_mock.ai_delay.return_value = 5 + host_mock.ai_sensitivity.return_value = 50 + host_mock.auto_track_disappear_time.return_value = 10 + host_mock.auto_track_limit_left.return_value = -1 + host_mock.auto_track_limit_right.return_value = -1 + host_mock.auto_track_stop_time.return_value = 10 + host_mock.daynight_threshold.return_value = 10 + host_mock.get_focus.return_value = 10 + host_mock.get_zoom.return_value = 20 + host_mock.image_brightness.return_value = 128 + host_mock.image_contrast.return_value = 128 + host_mock.image_hue.return_value = 128 + host_mock.image_saturation.return_value = 128 + host_mock.image_sharpness.return_value = 128 + host_mock.md_sensitivity.return_value = 30 + host_mock.pir_interval.return_value = 5 + host_mock.pir_sensitivity.return_value = 50 + host_mock.ptz_guard_time.return_value = 60 + host_mock.quick_reply_time.return_value = 10 + host_mock.volume.return_value = 80 + host_mock.volume_doorbell.return_value = 80 + host_mock.volume_speak.return_value = 80 + host_mock.alarm_volume = 90 + host_mock.message_volume = 70 + host_mock.whiteled_event_brightness.return_value = 100 + host_mock.whiteled_event_flash_time.return_value = 5 + host_mock.whiteled_event_on_time.return_value = 10 host_mock.whiteled_mode_list.return_value = [] host_mock.post_recording_time_list.return_value = [] host_mock.zoom_range.return_value = { @@ -195,6 +232,13 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_type_list.return_value = ["people"] host_mock.baichuan.smart_ai_index.return_value = 1 host_mock.baichuan.smart_ai_name.return_value = "zone1" + host_mock.baichuan.smart_ai_delay.return_value = 5 + host_mock.baichuan.smart_ai_sensitivity.return_value = 50 + host_mock.baichuan.audio_noise_reduction.return_value = 50 + host_mock.baichuan.cry_sensitivity.return_value = 3 + host_mock.baichuan.ir_brightness.return_value = 100 + host_mock.baichuan.pre_record_time.return_value = 10 + host_mock.baichuan.pre_record_battery_stop.return_value = 10 host_mock.whiteled_brightness.return_value = None def ai_detect_type(channel: int, object_type: str) -> str | None: diff --git a/tests/components/reolink/snapshots/test_binary_sensor.ambr b/tests/components/reolink/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..07aab58eb3a0 --- /dev/null +++ b/tests/components/reolink/snapshots/test_binary_sensor.ambr @@ -0,0 +1,752 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.test_reolink_cam_animal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_animal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Animal', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Animal', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'animal', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pet', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_animal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Animal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_animal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_baby_crying-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_baby_crying', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Baby crying', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Baby crying', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cry', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_cry', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_baby_crying-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Baby crying', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_baby_crying', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_bicycle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_bicycle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bicycle', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bicycle', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'non-motor_vehicle', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_non-motor_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_bicycle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Bicycle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_bicycle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_crossline_zone1_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_crossline_zone1_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Crossline zone1 person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Crossline zone1 person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'crossline_person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_crossline_person_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_crossline_zone1_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Crossline zone1 person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_crossline_zone1_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_face-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_face', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Face', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Face', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'face', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_face', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_face-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Face', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_face', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_intrusion_zone1_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_intrusion_zone1_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intrusion zone1 person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intrusion zone1 person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intrusion_person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_intrusion_person_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_intrusion_zone1_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Intrusion zone1 person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_intrusion_zone1_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_forgotten_zone1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_item_forgotten_zone1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Item forgotten zone1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Item forgotten zone1', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forgotten_item', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_forgotten_item_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_forgotten_zone1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Item forgotten zone1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_item_forgotten_zone1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_taken_zone1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_item_taken_zone1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Item taken zone1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Item taken zone1', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taken_item', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_taken_item_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_taken_zone1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Item taken zone1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_item_taken_zone1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_linger_zone1_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_linger_zone1_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Linger zone1 person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Linger zone1 person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'linger_person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_linger_person_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_linger_zone1_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Linger zone1 person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_linger_zone1_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'motion', + : 'test_reolink_cam Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_package-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_package', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Package', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Package', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'package', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_package', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_package-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Package', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_package', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_person', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_sleep_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_reolink_cam_sleep_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sleep status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep status', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sleep', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sleep', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_sleep_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Sleep status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_sleep_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vehicle', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vehicle', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_visitor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_reolink_cam_visitor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Visitor', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Visitor', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'visitor', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_visitor', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_visitor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Visitor', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_visitor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_button.ambr b/tests/components/reolink/snapshots/test_button.ambr new file mode 100644 index 000000000000..4ce6df9b29b3 --- /dev/null +++ b/tests/components/reolink/snapshots/test_button.ambr @@ -0,0 +1,914 @@ +# serializer version: 1 +# name: test_all_entities[button.test_reolink_cam_guard_go_to-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_guard_go_to', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard go to', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guard go to', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_go_to', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_guard_go_to', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_guard_go_to-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Guard go to', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_guard_go_to', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_guard_set_current_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_reolink_cam_guard_set_current_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard set current position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guard set current position', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_set', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_guard_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_guard_set_current_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Guard set current position', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_guard_set_current_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_pre_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_pre_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-siren', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-siren', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_siren', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_pre_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-siren', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_pre_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_reolink_cam_ptz_calibrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ calibrate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ calibrate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_calibrate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ calibrate', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_continuous_rotation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_continuous_rotation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ continuous rotation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ continuous rotation', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_auto', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_auto', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_continuous_rotation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ continuous rotation', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_continuous_rotation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ down', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_down', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ down', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ left', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ left', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_left', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ left', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_left_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ left down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ left down', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_left_down', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_left_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ left down', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_left_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_left_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ left up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ left up', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_left_up', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_left_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ left up', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_left_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ right', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ right', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_right', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ right', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_right_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ right down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ right down', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_right_down', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_right_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ right down', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_right_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_right_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ right up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ right up', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_right_up', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_right_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ right up', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_right_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ stop', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ stop', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_stop', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ stop', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ up', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_up', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ up', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_zoom_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ zoom in', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ zoom in', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_zoom_in', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_zoom_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ zoom in', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_zoom_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_reolink_cam_ptz_zoom_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ zoom out', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ zoom out', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_zoom_out', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_zoom_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ zoom out', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_zoom_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_reolink_cam_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'test_reolink_cam Restart', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_name_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_reolink_name_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_name_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'test_reolink_name Restart', + }), + 'context': , + 'entity_id': 'button.test_reolink_name_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_camera.ambr b/tests/components/reolink/snapshots/test_camera.ambr new file mode 100644 index 000000000000..2bad0463301f --- /dev/null +++ b/tests/components/reolink/snapshots/test_camera.ambr @@ -0,0 +1,425 @@ +# serializer version: 1 +# name: test_all_entities[camera.test_reolink_cam_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_clear?token=1caab5c3b3', + : 'test_reolink_cam Clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_fluent?token=1caab5c3b3', + : 'test_reolink_cam Fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_snapshots_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Snapshots clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snapshots clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snapshots_main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_snapshots', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_snapshots_clear?token=1caab5c3b3', + : 'test_reolink_cam Snapshots clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_snapshots_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_snapshots_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Snapshots fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snapshots fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snapshots_sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_snapshots_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_snapshots_fluent?token=1caab5c3b3', + : 'test_reolink_cam Snapshots fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_snapshots_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_telephoto_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'telephoto_main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_clear?token=1caab5c3b3', + : 'test_reolink_cam Telephoto clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_telephoto_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'telephoto_sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_fluent?token=1caab5c3b3', + : 'test_reolink_cam Telephoto fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_telephoto_snapshots_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto snapshots clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto snapshots clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'telephoto_snapshots_main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_snapshots_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_snapshots_clear?token=1caab5c3b3', + : 'test_reolink_cam Telephoto snapshots clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_snapshots_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.test_reolink_cam_telephoto_snapshots_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto snapshots fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto snapshots fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'telephoto_snapshots_sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_snapshots_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_snapshots_fluent?token=1caab5c3b3', + : 'test_reolink_cam Telephoto snapshots fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_snapshots_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_light.ambr b/tests/components/reolink/snapshots/test_light.ambr new file mode 100644 index 000000000000..18c2105dbb72 --- /dev/null +++ b/tests/components/reolink/snapshots/test_light.ambr @@ -0,0 +1,197 @@ +# serializer version: 1 +# name: test_all_entities[light.test_reolink_cam_floodlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 6000, + : 3000, + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_reolink_cam_floodlight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.test_reolink_cam_floodlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : , + : 3000, + : 'test_reolink_cam Floodlight', + : tuple( + 27.825, + 56.895, + ), + : 6000, + : 3000, + : tuple( + 255, + 177, + 110, + ), + : list([ + , + ]), + : , + : tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.test_reolink_cam_floodlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[light.test_reolink_cam_status_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.test_reolink_cam_status_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status LED', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status_led', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_status_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.test_reolink_cam_status_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'test_reolink_cam Status LED', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.test_reolink_cam_status_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[light.test_reolink_name_status_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.test_reolink_name_status_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status LED', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status_led', + 'unique_id': 'ABC1234567D89EFG_hub_status_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.test_reolink_name_status_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'test_reolink_name Status LED', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.test_reolink_name_status_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_number.ambr b/tests/components/reolink/snapshots/test_number.ambr new file mode 100644 index 000000000000..e6746d5a002a --- /dev/null +++ b/tests/components/reolink/snapshots/test_number.ambr @@ -0,0 +1,3104 @@ +# serializer version: 1 +# name: test_all_entities[number.test_reolink_cam_ai_animal_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_animal_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI animal delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI animal delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_animal_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_pet_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_animal_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI animal delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_animal_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_animal_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_animal_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI animal sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI animal sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_animal_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_pet_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_animal_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI animal sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_animal_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI bicycle delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI bicycle delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_non_motor_vehicle_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_non_motor_vehicle_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI bicycle delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI bicycle sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI bicycle sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_non_motor_vehicle_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_non_motor_vehicle_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI bicycle sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_crossline_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_crossline_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI crossline zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI crossline zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'crossline_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_crossline_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_crossline_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI crossline zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_crossline_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_face_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI face delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI face delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_face_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_face_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI face delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_face_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_face_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI face sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI face sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_face_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_face_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI face sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_face_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI intrusion zone1 delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI intrusion zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intrusion_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_intrusion_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI intrusion zone1 delay', + : 10, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI intrusion zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI intrusion zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intrusion_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_intrusion_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI intrusion zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item forgotten zone1 delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI item forgotten zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forgotten_item_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_forgotten_item_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI item forgotten zone1 delay', + : 30, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item forgotten zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI item forgotten zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forgotten_item_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_forgotten_item_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI item forgotten zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item taken zone1 delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI item taken zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taken_item_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_taken_item_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI item taken zone1 delay', + : 30, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item taken zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI item taken zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taken_item_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_taken_item_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI item taken zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI linger zone1 delay', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI linger zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'linger_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_linger_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI linger zone1 delay', + : 10, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI linger zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI linger zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'linger_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_linger_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI linger zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_package_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI package delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI package delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_package_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_package_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI package delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_package_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_package_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI package sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI package sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_package_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_package_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI package sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_package_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_person_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI person delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI person delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_person_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_person_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI person delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_person_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_person_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI person sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI person sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_person_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_person_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI person sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_person_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI vehicle delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI vehicle delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_vehicle_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_vehicle_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI vehicle delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI vehicle sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI vehicle sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_vehicle_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_vehicle_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI vehicle sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_audio_noise_reduction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_audio_noise_reduction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Audio noise reduction', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Audio noise reduction', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio_noise_reduction', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_audio_noise_reduction', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_audio_noise_reduction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Audio noise reduction', + : 5, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_audio_noise_reduction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_quick_reply_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_quick_reply_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto quick reply time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto quick reply time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_quick_reply_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_quick_reply_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_quick_reply_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Auto quick reply time', + : 60, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_quick_reply_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_disappear_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_disappear_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track disappear time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto track disappear time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_disappear_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_disappear_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_disappear_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Auto track disappear time', + : 60, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_disappear_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 2700, + : -1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track limit left', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto track limit left', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_limit_left', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_limit_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto track limit left', + : 2700, + : -1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 2700, + : -1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track limit right', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto track limit right', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_limit_right', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_limit_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto track limit right', + : 2700, + : -1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_stop_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_stop_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track stop time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto track stop time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_stop_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_stop_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_stop_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Auto track stop time', + : 60, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_stop_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_baby_cry_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_baby_cry_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Baby cry sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Baby cry sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cry_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_cry_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_baby_cry_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Baby cry sensitivity', + : 5, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_baby_cry_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_day_night_switch_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_day_night_switch_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day night switch threshold', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Day night switch threshold', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_night_switch_threshold', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_day_night_switch_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_day_night_switch_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Day night switch threshold', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_day_night_switch_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume_doorbell', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_volume_doorbell', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Doorbell volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight event brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight event brightness', + : 100, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_flash_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 10, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_flash_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event flash time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Floodlight event flash time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_flash_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_flash_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_flash_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Floodlight event flash time', + : 30, + : 10, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_flash_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_on_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 900, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_on_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event on time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Floodlight event on time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_on_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_on_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_on_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Floodlight event on time', + : 900, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_on_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_turn_on_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_turn_on_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight turn on brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight turn on brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_turn_on_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight turn on brightness', + : 100, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_turn_on_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_focus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_reolink_cam_focus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Focus', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Focus', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'focus', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_focus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_focus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Focus', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_focus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_guard_return_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 300, + : 10, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_guard_return_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard return time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Guard return time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_return_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_guard_return_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_guard_return_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Guard return time', + : 300, + : 10, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_guard_return_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image brightness', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_contrast-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_contrast', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image contrast', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image contrast', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_contrast', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_contrast', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_contrast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image contrast', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_contrast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_hue-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_hue', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image hue', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image hue', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_hue', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_hue', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_hue-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image hue', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_hue', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_saturation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_saturation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image saturation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image saturation', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_saturation', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_saturation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_saturation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image saturation', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_saturation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_sharpness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_sharpness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image sharpness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image sharpness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_sharpness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_sharpness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_sharpness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image sharpness', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_sharpness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_infrared_light_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_infrared_light_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Infrared light brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Infrared light brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ir_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ir_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_infrared_light_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Infrared light brightness', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_infrared_light_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_motion_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 50, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_motion_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_motion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_motion_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Motion sensitivity', + : 50, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_motion_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 120, + : 5, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pir_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR interval', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PIR interval', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_interval', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam PIR interval', + : 120, + : 5, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pir_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pir_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PIR sensitivity', + : 100, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pir_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_stop_battery_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 80, + : 10, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pre_recording_stop_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording stop battery level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording stop battery level', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record_battery_stop', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record_battery_stop', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_stop_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording stop battery level', + : 80, + : 10, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pre_recording_stop_battery_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 2, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pre_recording_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording time', + : 10, + : 2, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pre_recording_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_speak_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_speak_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Speak volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Speak volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume_speak', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_volume_speak', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_speak_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Speak volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_speak_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_zoom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_reolink_cam_zoom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zoom', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zoom', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zoom', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_zoom', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_zoom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Zoom', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_zoom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[number.test_reolink_name_alarm_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_name_alarm_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Alarm volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_volume', + 'unique_id': 'ABC1234567D89EFG_alarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_name_alarm_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Alarm volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_name_alarm_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_all_entities[number.test_reolink_name_message_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_name_message_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Message volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Message volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'message_volume', + 'unique_id': 'ABC1234567D89EFG_message_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_name_message_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Message volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_name_message_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_select.ambr b/tests/components/reolink/snapshots/test_select.ambr new file mode 100644 index 000000000000..fb91a9be89ae --- /dev/null +++ b/tests/components/reolink/snapshots/test_select.ambr @@ -0,0 +1,1383 @@ +# serializer version: 1 +# name: test_all_entities[select.test_reolink_cam_auto_quick_reply_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_auto_quick_reply_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto quick reply message', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto quick reply message', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_quick_reply_message', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_quick_reply_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_auto_quick_reply_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto quick reply message', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_auto_quick_reply_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_auto_track_method-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'digital', + 'digitalfirst', + 'pantiltfirst', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_auto_track_method', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track method', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto track method', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_method', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_auto_track_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto track method', + : list([ + 'digital', + 'digitalfirst', + 'pantiltfirst', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_auto_track_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'digitalfirst', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_binning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_binning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Binning mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Binning mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'binning_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_binning_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_binning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Binning mode', + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_binning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_bit_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_clear_bit_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear bit rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear bit rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_bit_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main_bit_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_bit_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Clear bit rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_clear_bit_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_encoding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'h264', + 'h265', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_clear_encoding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear encoding', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear encoding', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_encoding', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main_encoding', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_encoding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Clear encoding', + : list([ + 'h264', + 'h265', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_clear_encoding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_frame_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_clear_frame_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear frame rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear frame rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_frame_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main_frame_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_frame_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Clear frame rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_clear_frame_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_day_night_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'color', + 'blackwhite', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_day_night_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day night mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Day night mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_night_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_day_night_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_day_night_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Day night mode', + : list([ + 'auto', + 'color', + 'blackwhite', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_day_night_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'blackwhite', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_doorbell_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'stayoff', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_doorbell_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell LED', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_led', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_status_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_doorbell_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Doorbell LED', + : list([ + 'stayoff', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_doorbell_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_event_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'on', + 'flash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_floodlight_event_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight event mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_event_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight event mode', + : list([ + 'off', + 'on', + 'flash', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_floodlight_event_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_floodlight_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight mode', + : list([ + 'off', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_floodlight_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_bit_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_fluent_bit_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent bit rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent bit rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sub_bit_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub_bit_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_bit_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Fluent bit rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_fluent_bit_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_encoding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'h264', + 'h265', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_fluent_encoding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent encoding', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent encoding', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sub_encoding', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub_encoding', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_encoding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Fluent encoding', + : list([ + 'h264', + 'h265', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_fluent_encoding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_frame_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_fluent_frame_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent frame rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent frame rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sub_frame_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub_frame_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_frame_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Fluent frame rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_fluent_frame_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hdr-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_hdr', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HDR', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HDR', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hdr', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hdr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam HDR', + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_hdr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_alarm_ringtone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_hub_alarm_ringtone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub alarm ringtone', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub alarm ringtone', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_alarm_ringtone', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hub_alarm_ringtone', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_alarm_ringtone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hub alarm ringtone', + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_hub_alarm_ringtone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'originaltune', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_visitor_ringtone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_hub_visitor_ringtone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub visitor ringtone', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub visitor ringtone', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_visitor_ringtone', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hub_visitor_ringtone', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_visitor_ringtone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hub visitor ringtone', + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_hub_visitor_ringtone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'originaltune', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_image_exposure_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'lownoise', + 'antismearing', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_image_exposure_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image exposure mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image exposure mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'exposure', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_exposure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_image_exposure_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image exposure mode', + : list([ + 'auto', + 'lownoise', + 'antismearing', + 'manual', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_image_exposure_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_play_quick_reply_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_reolink_cam_play_quick_reply_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Play quick reply message', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Play quick reply message', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'play_quick_reply_message', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_play_quick_reply_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_play_quick_reply_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Play quick reply message', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_play_quick_reply_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_post_recording_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_post_recording_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Post-recording time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Post-recording time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'post_rec_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_post_rec_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_post_recording_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Post-recording time', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_post_recording_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_pre_recording_frame_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '1', + '2', + '5', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_pre_recording_frame_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording frame rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording frame rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record_fps', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record_fps', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_pre_recording_frame_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording frame rate', + : list([ + '1', + '2', + '5', + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_pre_recording_frame_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_ptz_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_reolink_cam_ptz_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ preset', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ preset', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_preset', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_ptz_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ preset', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_ptz_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_name_recording_packing_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '30 Minutes', + '60 Minutes', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_name_recording_packing_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Recording packing time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Recording packing time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'packing_time', + 'unique_id': 'ABC1234567D89EFG_packing_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_name_recording_packing_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Recording packing time', + : list([ + '30 Minutes', + '60 Minutes', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_name_recording_packing_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60 Minutes', + }) +# --- +# name: test_all_entities[select.test_reolink_name_scene_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'home', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_name_scene_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Scene mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scene mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'scene_mode', + 'unique_id': 'ABC1234567D89EFG_scene_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_name_scene_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Scene mode', + : list([ + 'off', + 'home', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_name_scene_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_sensor.ambr b/tests/components/reolink/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..d98e409ba9c3 --- /dev/null +++ b/tests/components/reolink/snapshots/test_sensor.ambr @@ -0,0 +1,762 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_reolink_cam_animal_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'dog', + 'cat', + 'squirrel', + 'fox', + 'bear', + 'cow', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_reolink_cam_animal_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Animal type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Animal type', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'animal_type', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_animal_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_animal_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Animal type', + : list([ + 'dog', + 'cat', + 'squirrel', + 'fox', + 'bear', + 'cow', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_animal_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'dog', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'test_reolink_cam Battery', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'discharging', + 'charging', + 'chargecomplete', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_battery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Battery state', + : list([ + 'discharging', + 'charging', + 'chargecomplete', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'test_reolink_cam Battery temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_day_night_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'day', + 'night', + 'led_day', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_day_night_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day night state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Day night state', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_night_state', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_day_night_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_day_night_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Day night state', + : list([ + 'day', + 'night', + 'led_day', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_day_night_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'day', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_person_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'man', + 'woman', + 'child', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_reolink_cam_person_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Person type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Person type', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'person_type', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_person_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_person_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Person type', + : list([ + 'man', + 'woman', + 'child', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_person_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_pan_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_ptz_pan_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ pan position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ pan position', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_pan_position', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_pan_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_pan_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ pan position', + : , + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_ptz_pan_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_tilt_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_ptz_tilt_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ tilt position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ tilt position', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_tilt_position', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_tilt_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_tilt_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ tilt position', + : , + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_ptz_tilt_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_vehicle_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'sedan', + 'suv', + 'pickup_truck', + 'bus', + 'van', + 'truck', + 'motorcycle', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_reolink_cam_vehicle_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vehicle type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vehicle type', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_type', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_vehicle_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_vehicle_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Vehicle type', + : list([ + 'sedan', + 'suv', + 'pickup_truck', + 'bus', + 'van', + 'truck', + 'motorcycle', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_vehicle_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'motorcycle', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_wi_fi_signal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_wi_fi_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_wifi_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_wi_fi_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'test_reolink_cam Wi-Fi signal', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_wi_fi_signal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-45', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_name_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'CPU usage', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU usage', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cpu_usage', + 'unique_id': 'ABC1234567D89EFG_cpu_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name CPU usage', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_name_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_hdd_0_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_name_hdd_0_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HDD 0 storage', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HDD 0 storage', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hdd_storage', + 'unique_id': 'ABC1234567D89EFG_0_storage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_hdd_0_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name HDD 0 storage', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_name_hdd_0_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '95', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_wi_fi_signal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_name_wi_fi_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal', + 'unique_id': 'ABC1234567D89EFG_wifi_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_wi_fi_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'test_reolink_name Wi-Fi signal', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_name_wi_fi_signal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-45', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_siren.ambr b/tests/components/reolink/snapshots/test_siren.ambr new file mode 100644 index 000000000000..d47d1b91c310 --- /dev/null +++ b/tests/components/reolink/snapshots/test_siren.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_all_entities[siren.test_reolink_cam_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test_reolink_cam_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Siren', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'siren', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[siren.test_reolink_cam_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Siren', + : , + }), + 'context': , + 'entity_id': 'siren.test_reolink_cam_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[siren.test_reolink_name_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test_reolink_name_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Siren', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'siren', + 'unique_id': 'ABC1234567D89EFG_siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[siren.test_reolink_name_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Siren', + : , + }), + 'context': , + 'entity_id': 'siren.test_reolink_name_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_switch.ambr b/tests/components/reolink/snapshots/test_switch.ambr new file mode 100644 index 000000000000..d74c73e86775 --- /dev/null +++ b/tests/components/reolink/snapshots/test_switch.ambr @@ -0,0 +1,1301 @@ +# serializer version: 1 +# name: test_all_entities[switch.test_reolink_cam_auto_focus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_auto_focus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto focus', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto focus', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_focus', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_focus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_auto_focus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto focus', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_auto_focus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_auto_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_auto_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto tracking', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto tracking', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_tracking', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_auto_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto tracking', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_auto_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_doorbell_button_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_doorbell_button_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell button sound', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell button sound', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_button_sound', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_doorbell_button_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_doorbell_button_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Doorbell button sound', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_doorbell_button_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_email_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_email_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Email on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Email on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'email', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_email', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_email_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Email on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_email_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ftp_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_ftp_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'FTP upload', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FTP upload', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ftp_upload', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ftp_upload', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ftp_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam FTP upload', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_ftp_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_guard_return-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_guard_return', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard return', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guard return', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_return', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_gaurd_return', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_guard_return-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Guard return', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_guard_return', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hardwired_chime_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_hardwired_chime_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hardwired chime enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hardwired chime enabled', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hardwired_chime_enabled', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hardwired_chime_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hardwired_chime_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hardwired chime enabled', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_hardwired_chime_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hub_ringtone_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_hub_ringtone_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub ringtone on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub ringtone on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_ringtone_on_event', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_buzzer', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hub_ringtone_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hub ringtone on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_hub_ringtone_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_infrared_lights_in_night_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_infrared_lights_in_night_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Infrared lights in night mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Infrared lights in night mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ir_lights', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ir_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_infrared_lights_in_night_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Infrared lights in night mode', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_infrared_lights_in_night_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_manual_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_manual_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Manual record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Manual record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_record', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_manual_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_manual_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Manual record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_manual_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pir_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR enabled', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_enabled', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PIR enabled', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pir_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_reduce_false_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pir_reduce_false_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR reduce false alarm', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR reduce false alarm', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_reduce_alarm', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_reduce_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_reduce_false_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PIR reduce false alarm', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pir_reduce_false_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pre_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pre_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_siren_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pre_siren_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-siren on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-siren on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_siren_on_event', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_siren_on_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_siren_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-siren on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pre_siren_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mask-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_privacy_mask', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mask', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mask', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mask', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_privacy_mask', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mask-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Privacy mask', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_privacy_mask', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_privacy_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_privacy_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Privacy mode', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ptz_patrol-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_reolink_cam_ptz_patrol', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ patrol', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ patrol', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_patrol', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_patrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ptz_patrol-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ patrol', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_ptz_patrol', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_push_notifications-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_push_notifications', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Push notifications', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Push notifications', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'push_notifications', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_push_notifications', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_push_notifications-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Push notifications', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_push_notifications', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record_audio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_record_audio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record audio', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record audio', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_audio', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_record_audio', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record_audio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Record audio', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_record_audio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_siren_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_siren_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Siren on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_on_event', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_siren_on_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_siren_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Siren on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_siren_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_email_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_email_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Email on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Email on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'email', + 'unique_id': 'ABC1234567D89EFG_email', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_email_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Email on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_email_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_ftp_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_ftp_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'FTP upload', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FTP upload', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ftp_upload', + 'unique_id': 'ABC1234567D89EFG_ftp_upload', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_ftp_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name FTP upload', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_ftp_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_hub_ringtone_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_hub_ringtone_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub ringtone on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub ringtone on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_ringtone_on_event', + 'unique_id': 'ABC1234567D89EFG_buzzer', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_hub_ringtone_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Hub ringtone on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_hub_ringtone_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_push_notifications-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_push_notifications', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Push notifications', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Push notifications', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'push_notifications', + 'unique_id': 'ABC1234567D89EFG_push_notifications', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_push_notifications-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Push notifications', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_push_notifications', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record', + 'unique_id': 'ABC1234567D89EFG_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_update.ambr b/tests/components/reolink/snapshots/test_update.ambr new file mode 100644 index 000000000000..ac539af10e2d --- /dev/null +++ b/tests/components/reolink/snapshots/test_update.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_all_entities[update.test_reolink_cam_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_reolink_cam_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[update.test_reolink_cam_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/reolink/icon.png', + : 'test_reolink_cam Firmware', + : False, + : 'v1.1.0.0.0.0000', + : 'v1.1.0.0.0.0000', + : None, + : 'https://reolink.com/download-center/', + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.test_reolink_cam_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[update.test_reolink_name_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_reolink_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[update.test_reolink_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/reolink/icon.png', + : 'test_reolink_name Firmware', + : False, + : 'v1.1.0.0.0.0000', + : 'v1.1.0.0.0.0000', + : None, + : 'https://reolink.com/download-center/', + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.test_reolink_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 1ca6442662f8..3bafa15d0680 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.const import DOMAIN from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN @@ -14,6 +15,7 @@ from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from . import setup_integration from .conftest import ( TEST_CAM_NAME, TEST_DUO_MODEL, @@ -23,10 +25,26 @@ from .conftest import ( TEST_UID_CAM, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_motion_sensor( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index 8db5dc8b7c66..fa3d832c8fb1 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest from reolink_aio.exceptions import ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.reolink.const import DOMAIN @@ -12,10 +13,28 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_button( diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 533729187df3..efcfd0c64c6e 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest from reolink_aio.exceptions import ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.camera import ( CameraState, @@ -14,13 +15,35 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with ( + patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.CAMERA], + ), + # keep the camera access tokens deterministic for the snapshot + patch("random.SystemRandom.getrandbits", return_value=123123123123), + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_camera( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 0896c1df87d5..0b181ca91ce6 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call, patch import pytest from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,10 +21,28 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.LIGHT], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 3e49a5dd4a78..7afcfab6f2dc 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest from reolink_aio.api import Chime from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, @@ -15,10 +16,28 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.NUMBER], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_number( diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 36c19f1c3f73..920520aeefd3 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -6,6 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.api import Chime from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN @@ -20,9 +21,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SELECT], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_floodlight_mode_select( diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index 9049d5906fca..86ae86fed09c 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -3,14 +3,33 @@ from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -24,6 +43,7 @@ async def test_sensors( reolink_host.wifi_connection.return_value = True reolink_host.wifi_signal.return_value = -55 reolink_host.hdd_list = [0] + reolink_host.hdd_type.return_value = "SD" reolink_host.hdd_storage.return_value = 95 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index c3ed7708f526..e01b8c72d9e5 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import ( ATTR_DURATION, @@ -22,10 +23,28 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SIREN], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_siren( diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index cd0c436bdea5..c55bdd688491 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -6,6 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.api import Chime from reolink_aio.exceptions import ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -21,10 +22,28 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_switch( diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index f5c3420582b4..e41e51767a42 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -8,6 +8,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.exceptions import ApiError, ReolinkError from reolink_aio.software_version import NewSoftwareVersion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.update import POLL_AFTER_INSTALL, POLL_PROGRESS from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL @@ -15,13 +16,32 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import WebSocketGenerator + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + TEST_DOWNLOAD_URL = "https://reolink.com/test" TEST_RELEASE_NOTES = "bugfix 1, bugfix 2" From f56bd0401c98d38151c69e37ace026f8d8fee5e1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:28:29 +0200 Subject: [PATCH 517/707] Move get_in_zones_attribute to a shared zone helpers module (#176380) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/zone/condition.py | 18 +++------------- homeassistant/components/zone/helpers.py | 24 ++++++++++++++++++++++ homeassistant/components/zone/trigger.py | 6 +++--- 3 files changed, 30 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/zone/helpers.py diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index 7a553cc97f3a..ae00b0bff599 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -43,6 +43,7 @@ from homeassistant.helpers.typing import ConfigType from . import in_zone from .const import DOMAIN +from .helpers import get_in_zones_attribute _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_ENTITY_ID): cv.entity_ids, @@ -51,19 +52,6 @@ _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { _CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT}) -def _get_in_zones_attribute(state: State) -> str | None: - """Return the in_zones attribute for the tracked entity, or None. - - Only person and device_tracker entities report zone membership; each - exposes it under its own platform enum. Any other domain returns None. - """ - if state.domain == PERSON_DOMAIN: - return PersonEntityStateAttribute.IN_ZONES - if state.domain == DEVICE_TRACKER_DOMAIN: - return DeviceTrackerEntityStateAttribute.IN_ZONES - return None - - def zone( hass: HomeAssistant, zone_ent: str | State | None, @@ -101,7 +89,7 @@ def zone( # Prefer the in_zones attribute reported by the entity (e.g. person, # device_tracker) over recomputing membership from coordinates. - if (in_zones_attr := _get_in_zones_attribute(entity)) is not None and ( + if (in_zones_attr := get_in_zones_attribute(entity)) is not None and ( in_zones := entity.attributes.get(in_zones_attr) ) is not None: return zone_ent.entity_id in in_zones @@ -219,7 +207,7 @@ class _ZoneTargetConditionBase(EntityConditionBase): def _in_target_zone(self, entity_state: State) -> bool: """Check if the entity is currently in the selected zone.""" - if (in_zones_attr := _get_in_zones_attribute(entity_state)) and ( + if (in_zones_attr := get_in_zones_attribute(entity_state)) and ( in_zones := entity_state.attributes.get(in_zones_attr) ): return self._zone in in_zones diff --git a/homeassistant/components/zone/helpers.py b/homeassistant/components/zone/helpers.py new file mode 100644 index 000000000000..5e83c6e9c7b3 --- /dev/null +++ b/homeassistant/components/zone/helpers.py @@ -0,0 +1,24 @@ +"""Helpers for the zone integration.""" + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, +) +from homeassistant.components.person import ( + DOMAIN as PERSON_DOMAIN, + PersonEntityStateAttribute, +) +from homeassistant.core import State + + +def get_in_zones_attribute(state: State) -> str | None: + """Return the in_zones attribute for the tracked entity, or None. + + Only person and device_tracker entities report zone membership; each + exposes it under its own platform enum. Any other domain returns None. + """ + if state.domain == PERSON_DOMAIN: + return PersonEntityStateAttribute.IN_ZONES + if state.domain == DEVICE_TRACKER_DOMAIN: + return DeviceTrackerEntityStateAttribute.IN_ZONES + return None diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index eaf36ed40e41..c23a3bb6f3c9 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -44,8 +44,8 @@ from homeassistant.helpers.trigger import ( from homeassistant.helpers.typing import ConfigType from . import condition -from .condition import _get_in_zones_attribute from .const import DOMAIN +from .helpers import get_in_zones_attribute EVENT_ENTER = "enter" EVENT_LEAVE = "leave" @@ -64,7 +64,7 @@ def _state_has_zone_info(state: State) -> bool: tracker); other entities are matched by their coordinates. """ return location.has_location(state) or ( - (in_zones_attr := _get_in_zones_attribute(state)) is not None + (in_zones_attr := get_in_zones_attribute(state)) is not None and in_zones_attr in state.attributes ) @@ -199,7 +199,7 @@ class ZoneTriggerBase(EntityTriggerBase): def _in_target_zone(self, state: State) -> bool: """Check if the entity is in the selected zone.""" - if (in_zones_attr := _get_in_zones_attribute(state)) and ( + if (in_zones_attr := get_in_zones_attribute(state)) and ( in_zones := state.attributes.get(in_zones_attr) ): return self._zone in in_zones From 5d57d3bf20c2a8e2cf75c86a6d62e249be51a976 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Mon, 13 Jul 2026 12:28:42 +0200 Subject: [PATCH 518/707] Bump python-duco-connectivity to 0.8.0 (#176383) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 85cc883e0536..f3806627f2a7 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.7.1"], + "requirements": ["python-duco-connectivity==0.8.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/requirements_all.txt b/requirements_all.txt index 1dec7943a0c4..5a5e968f9ab9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2646,7 +2646,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.7.1 +python-duco-connectivity==0.8.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 From 40ca2bdab26821acbe18befb67603718822180d3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:32:12 +0200 Subject: [PATCH 519/707] Use EntityStateAttribute enum in NWS (#176382) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/nws/__init__.py | 11 +++++------ homeassistant/components/nws/config_flow.py | 11 +++++++---- homeassistant/components/nws/coordinator.py | 6 +++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 80b6e67aa217..eebc2751dfab 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -9,11 +9,10 @@ from pynws import NwsNoDataError, SimpleNWS, call_with_retry from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + EntityStateAttribute, Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback @@ -102,8 +101,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool: translation_key="entity_unavailable", translation_placeholders={"entity_id": location_entity_id}, ) - latitude = state.attributes[ATTR_LATITUDE] - longitude = state.attributes[ATTR_LONGITUDE] + latitude = state.attributes[EntityStateAttribute.LATITUDE] + longitude = state.attributes[EntityStateAttribute.LONGITUDE] station = None else: latitude = entry.data[CONF_LATITUDE] @@ -217,8 +216,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool: new_state = event.data["new_state"] if new_state is None or not has_location(new_state): return - new_lat = new_state.attributes[ATTR_LATITUDE] - new_lon = new_state.attributes[ATTR_LONGITUDE] + new_lat = new_state.attributes[EntityStateAttribute.LATITUDE] + new_lon = new_state.attributes[EntityStateAttribute.LONGITUDE] if ( new_lat == entry.runtime_data.latitude and new_lon == entry.runtime_data.longitude diff --git a/homeassistant/components/nws/config_flow.py b/homeassistant/components/nws/config_flow.py index b1b5e237b07b..e107d5f54215 100644 --- a/homeassistant/components/nws/config_flow.py +++ b/homeassistant/components/nws/config_flow.py @@ -9,11 +9,10 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -134,8 +133,12 @@ class NWSConfigFlow(ConfigFlow, domain=DOMAIN): self.hass, { CONF_API_KEY: user_input[CONF_API_KEY], - CONF_LATITUDE: state.attributes[ATTR_LATITUDE], - CONF_LONGITUDE: state.attributes[ATTR_LONGITUDE], + CONF_LATITUDE: state.attributes[ + EntityStateAttribute.LATITUDE + ], + CONF_LONGITUDE: state.attributes[ + EntityStateAttribute.LONGITUDE + ], }, ) return self.async_create_entry(title=location_entity, data=data) diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py index a838907823f2..4824cbd85217 100644 --- a/homeassistant/components/nws/coordinator.py +++ b/homeassistant/components/nws/coordinator.py @@ -8,7 +8,7 @@ import aiohttp from aiohttp import ClientResponseError from pynws import NwsError, NwsNoDataError, SimpleNWS, call_with_retry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY +from homeassistant.const import CONF_API_KEY, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -90,8 +90,8 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): self._location_entity_id, ) return - new_lat = state.attributes[ATTR_LATITUDE] - new_lon = state.attributes[ATTR_LONGITUDE] + new_lat = state.attributes[EntityStateAttribute.LATITUDE] + new_lon = state.attributes[EntityStateAttribute.LONGITUDE] if self._previous_position is not None: prev_lat, prev_lon = self._previous_position if new_lat == prev_lat and new_lon == prev_lon: From 9443d51c81f8bde7ee7460d409f3849ea2903955 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:32:35 +0200 Subject: [PATCH 520/707] Use EntityStateAttribute enum in DWD Weather Warnings (#176381) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/dwd_weather_warnings/util.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/util.py b/homeassistant/components/dwd_weather_warnings/util.py index 01398ef595c6..1f35d7ecfadd 100644 --- a/homeassistant/components/dwd_weather_warnings/util.py +++ b/homeassistant/components/dwd_weather_warnings/util.py @@ -1,6 +1,6 @@ """Util functions for the dwd_weather_warnings integration.""" -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,19 +20,20 @@ def get_position_data( if entity is None: raise EntityNotFoundError(f"Failed to find entity {registry_entry.entity_id}") - latitude = entity.attributes.get(ATTR_LATITUDE) + latitude = entity.attributes.get(EntityStateAttribute.LATITUDE) if not latitude: raise AttributeError( - f"Failed to find attribute '{ATTR_LATITUDE}' in {registry_entry.entity_id}", - ATTR_LATITUDE, + f"Failed to find attribute '{EntityStateAttribute.LATITUDE}'" + f" in {registry_entry.entity_id}", + EntityStateAttribute.LATITUDE, ) - longitude = entity.attributes.get(ATTR_LONGITUDE) + longitude = entity.attributes.get(EntityStateAttribute.LONGITUDE) if not longitude: raise AttributeError( - f"Failed to find attribute '{ATTR_LONGITUDE}'" + f"Failed to find attribute '{EntityStateAttribute.LONGITUDE}'" f" in {registry_entry.entity_id}", - ATTR_LONGITUDE, + EntityStateAttribute.LONGITUDE, ) return (latitude, longitude) From 716aab6a11098874b77fb7f080a93a2283039ca9 Mon Sep 17 00:00:00 2001 From: kristbaum Date: Mon, 13 Jul 2026 12:37:40 +0200 Subject: [PATCH 521/707] Raise luci quality scale to silver (#176240) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/luci/manifest.json | 2 +- .../components/luci/quality_scale.yaml | 100 ++++++++++++++++++ homeassistant/components/luci/strings.json | 11 ++ script/hassfest/quality_scale.py | 2 - 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/luci/quality_scale.yaml diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index 11f281645750..111bb6a6bd76 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], - "quality_scale": "legacy", + "quality_scale": "silver", "requirements": ["openwrt-luci-rpc==1.1.17"] } diff --git a/homeassistant/components/luci/quality_scale.yaml b/homeassistant/components/luci/quality_scale.yaml new file mode 100644 index 000000000000..37debf5684b9 --- /dev/null +++ b/homeassistant/components/luci/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide any actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide any actions. + docs-conditions: + status: exempt + comment: This integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: Entities do not subscribe to events; they are updated via the coordinator. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: todo + comment: Tracked clients are represented as scanner entities without a device registry entry. + diagnostics: todo + discovery: + status: todo + comment: The router could be discovered via DHCP. + discovery-update-info: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: todo + comment: Clients connecting after setup are not added until Home Assistant restarts. + entity-category: + status: exempt + comment: This integration only creates device tracker entities. + entity-device-class: + status: exempt + comment: This integration only creates device tracker entities. + entity-disabled-by-default: + status: exempt + comment: This integration only creates device tracker entities. + entity-translations: + status: exempt + comment: Device tracker entities are named after the tracked client's hostname. + exception-translations: todo + icon-translations: + status: exempt + comment: Device tracker entities use the icon of their source type. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration has no repairs beyond the deprecated YAML import issue. + stale-devices: + status: todo + comment: Clients that stop connecting are not removed automatically. + + # Platinum + async-dependency: + status: todo + comment: The openwrt-luci-rpc library is synchronous and is run in the executor. + inject-websession: + status: exempt + comment: The openwrt-luci-rpc library uses requests and does not accept an injected websession. + strict-typing: todo diff --git a/homeassistant/components/luci/strings.json b/homeassistant/components/luci/strings.json index d9600a9c9a25..fe28ec962dcc 100644 --- a/homeassistant/components/luci/strings.json +++ b/homeassistant/components/luci/strings.json @@ -15,6 +15,10 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password to log in to the OpenWrt router.", + "username": "The username to log in to the OpenWrt router." } }, "user": { @@ -24,6 +28,13 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenWrt router.", + "password": "The password to log in to the OpenWrt router.", + "ssl": "Whether to connect to the router using SSL.", + "username": "The username to log in to the OpenWrt router.", + "verify_ssl": "Whether to verify the router's SSL certificate." } } } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 6683e4be8d97..0a872ddbb8d5 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -546,7 +546,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "london_underground", "lookin", "loqed", - "luci", "luftdaten", "lupusec", "lutron", @@ -1495,7 +1494,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "london_underground", "lookin", "loqed", - "luci", "luftdaten", "lupusec", "lutron", From c520ebc2d513cb400caaa8258a7ecc9c0b454577 Mon Sep 17 00:00:00 2001 From: Amit Krishna <218109745+amitkio@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:08:46 +0530 Subject: [PATCH 522/707] Add reconfiguration support for energieleser (#176214) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/energieleser/config_flow.py | 37 ++++++++ .../energieleser/quality_scale.yaml | 2 +- .../components/energieleser/strings.json | 14 ++- .../energieleser/test_config_flow.py | 95 +++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energieleser/config_flow.py b/homeassistant/components/energieleser/config_flow.py index 7a3a38ed1b81..4fccd3beec17 100755 --- a/homeassistant/components/energieleser/config_flow.py +++ b/homeassistant/components/energieleser/config_flow.py @@ -141,6 +141,43 @@ class EnergieleserConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + client = EnergieleserClient( + host=host, session=async_get_clientsession(self.hass) + ) + try: + device = await client.get_device() + except EnergieleserConnectionError: + errors["base"] = "cannot_connect" + except EnergieleserUnknownDeviceError: + errors["base"] = "unknown_device_type" + except EnergieleserError: + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.device_id) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: host}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_SCHEMA, + suggested_values=entry.data | (user_input or {}), + ), + errors=errors, + ) + def _create_entry( self, host: str, title: str, device_id: str, sw_version: str | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index 64f37f0fcd8c..6fb61d22a818 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -68,7 +68,7 @@ rules: entity-translations: done exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/energieleser/strings.json b/homeassistant/components/energieleser/strings.json index 2138e95ee459..dbfaf215b536 100755 --- a/homeassistant/components/energieleser/strings.json +++ b/homeassistant/components/energieleser/strings.json @@ -3,8 +3,10 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "unknown_device_type": "This isn't a supported energieleser product. Please check that the device is a stromleser, gasleser, wasserleser, or wärmeleser." + "unknown_device_type": "This isn't a supported energieleser product. Please check that the device is a stromleser, gasleser, wasserleser, or wärmeleser.", + "wrong_device": "The device at this IP address does not match the originally configured device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -13,6 +15,16 @@ }, "flow_title": "{device_type} · {host}", "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::energieleser::config::step::user::data_description::host%]" + }, + "description": "Update the IP address or hostname of your energieleser device.", + "title": "Reconfigure energieleser" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/tests/components/energieleser/test_config_flow.py b/tests/components/energieleser/test_config_flow.py index e3629dc60a03..7ff0afc85a07 100755 --- a/tests/components/energieleser/test_config_flow.py +++ b/tests/components/energieleser/test_config_flow.py @@ -307,3 +307,98 @@ async def test_zeroconf_flow_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_reason + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_energieleser_client") +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_stromleser_config_entry: MockConfigEntry, +) -> None: + """Test a successful reconfiguration flow.""" + mock_stromleser_config_entry.add_to_hass(hass) + + result = await mock_stromleser_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.102"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_stromleser_config_entry.data[CONF_HOST] == "192.168.1.102" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow_another_device( + hass: HomeAssistant, + mock_stromleser_config_entry: MockConfigEntry, + mock_energieleser_client: AsyncMock, + mock_gasleser_device: GasleserDevice, +) -> None: + """Test reconfiguration flow with a different device.""" + mock_stromleser_config_entry.add_to_hass(hass) + + mock_energieleser_client.get_device.return_value = mock_gasleser_device + + result = await mock_stromleser_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.105"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param( + EnergieleserConnectionError("boom"), "cannot_connect", id="cannot_connect" + ), + pytest.param( + EnergieleserUnknownDeviceError("FOO_0000000001"), + "unknown_device_type", + id="unknown_device_type", + ), + pytest.param( + EnergieleserError("boom"), + "unknown", + id="unknown", + ), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_stromleser_config_entry: MockConfigEntry, + mock_energieleser_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test client errors during reconfiguration flow.""" + mock_stromleser_config_entry.add_to_hass(hass) + mock_energieleser_client.get_device.side_effect = side_effect + + result = await mock_stromleser_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.105"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_energieleser_client.get_device.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.102"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From 042c371506fbee985e7eb59fd27a032a8e389226 Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Mon, 13 Jul 2026 16:43:27 +0530 Subject: [PATCH 523/707] Fix duplicate Hikvision binary sensor unique IDs (#176345) --- .../components/hikvision/binary_sensor.py | 34 +++++++++++------ .../hikvision/test_binary_sensor.py | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index eceaf0ebdbc0..2d0404f0def3 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -252,17 +252,29 @@ async def async_setup_entry( sensor_type, ) - async_add_entities( - HikvisionBinarySensor( - entry=entry, - description=BINARY_SENSOR_DESCRIPTIONS[sensor_type], - sensor_type=sensor_type, - channel=channel_info[1], - ) - for sensor_type, channel_list in sensors.items() - if sensor_type in BINARY_SENSOR_DESCRIPTIONS - for channel_info in channel_list - ) + entities: list[HikvisionBinarySensor] = [] + for sensor_type, channel_list in sensors.items(): + if sensor_type not in BINARY_SENSOR_DESCRIPTIONS: + continue + # pyhik can report the same channel more than once for a sensor type + # (e.g. when a channel has several notification methods enabled), so + # deduplicate on the channel to avoid colliding unique IDs. + seen_channels: set[int] = set() + for channel_info in channel_list: + channel = channel_info[1] + if channel in seen_channels: + continue + seen_channels.add(channel) + entities.append( + HikvisionBinarySensor( + entry=entry, + description=BINARY_SENSOR_DESCRIPTIONS[sensor_type], + sensor_type=sensor_type, + channel=channel, + ) + ) + + async_add_entities(entities) class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity): diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py index 09fa8d1f26d7..45eb0e76e67c 100644 --- a/tests/components/hikvision/test_binary_sensor.py +++ b/tests/components/hikvision/test_binary_sensor.py @@ -165,6 +165,43 @@ async def test_binary_sensor_nvr_device( assert len(states) == 2 +@pytest.mark.parametrize("amount_of_channels", [2]) +async def test_binary_sensor_duplicate_channels_deduplicated( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test duplicate channel entries do not create colliding unique IDs. + + pyhik can report the same channel several times for a sensor type when the + channel has multiple notification methods enabled. Only one entity should be + created per channel and no duplicate unique ID warnings should occur. + """ + mock_hikcamera.return_value.get_type = "NVR" + mock_hikcamera.return_value.current_event_states = { + # Channel 1 reported three times, channel 2 reported twice. + "Line Crossing": [(False, 1), (False, 1), (False, 1), (False, 2), (False, 2)], + } + + await setup_integration(hass, mock_config_entry) + + # One entity per distinct channel, not one per reported entry. + states = hass.states.async_entity_ids("binary_sensor") + assert len(states) == 2 + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == { + f"{TEST_DEVICE_ID}_Line Crossing_1", + f"{TEST_DEVICE_ID}_Line Crossing_2", + } + + async def test_binary_sensor_state_on( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From d404ee2cc090dd1316cd029ac937759231157ba2 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Mon, 13 Jul 2026 19:14:36 +0800 Subject: [PATCH 524/707] Add izone climate test for fault-shaped controller bootstrap (#176342) Co-authored-by: Cursor --- tests/components/izone/test_climate.py | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/components/izone/test_climate.py b/tests/components/izone/test_climate.py index a0a7a922bce8..222712bd5657 100644 --- a/tests/components/izone/test_climate.py +++ b/tests/components/izone/test_climate.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ClimateEntityFeature from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er from . import setup_controller, setup_integration @@ -307,3 +308,42 @@ async def test_setup_entry_only_adds_entities_for_matching_config_entry( unique_ids = {entity.unique_id for entity in entry_entities} assert unique_ids == {"000000001", "000000001_z1"} + + +@pytest.mark.parametrize("mock_zones", [[]]) +@pytest.mark.parametrize( + "mock_controller", + [ + create_mock_controller( + ras_mode="zones", + sys_type="0", + zones_total=0, + free_air_enabled=False, + ) + ], +) +async def test_controller_device_init_fault_bootstrap( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_controller: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Controller climate entity is created with fault-shaped controller defaults.""" + await setup_integration(hass, mock_config_entry) + await setup_controller(hass, mock_discovery, mock_controller) + + entity_id = "climate.izone_controller_000000001" + assert hass.states.get(entity_id) is not None + + entry_entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert {entity.unique_id for entity in entry_entities} == {"000000001"} + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry is not None + assert device_entry.model == "0" From d85846cf04c7c9c5b16181cf7984aaf3964a47c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Mon, 13 Jul 2026 13:47:47 +0200 Subject: [PATCH 525/707] Improve and extend WMS WebControl pro tests (#176300) --- tests/components/wmspro/conftest.py | 8 +- .../fixtures/config_prod_light_switch.json | 32 +++++ .../fixtures/status_prod_light_switch.json | 22 +++ tests/components/wmspro/test_button.py | 15 +- tests/components/wmspro/test_cover.py | 135 +++++++++++++----- tests/components/wmspro/test_init.py | 8 +- tests/components/wmspro/test_light.py | 58 +++++--- tests/components/wmspro/test_number.py | 29 ++-- tests/components/wmspro/test_scene.py | 2 + tests/components/wmspro/test_services.py | 6 +- tests/components/wmspro/test_switch.py | 20 +-- 11 files changed, 246 insertions(+), 89 deletions(-) create mode 100644 tests/components/wmspro/fixtures/config_prod_light_switch.json create mode 100644 tests/components/wmspro/fixtures/status_prod_light_switch.json diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index 35bcaa8658ed..84136524ca7c 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -83,10 +83,12 @@ async def mock_hub_configuration( request: pytest.FixtureRequest, hass: HomeAssistant ) -> AsyncGenerator[AsyncMock]: """Override WebControlPro._getConfiguration with a param fixture file.""" + hub_config = await async_load_json_object_fixture(hass, request.param, DOMAIN) with patch( "wmspro.webcontrol.WebControlPro._getConfiguration", - return_value=await async_load_json_object_fixture(hass, request.param, DOMAIN), + return_value=hub_config, ) as mock_hub_configuration: + mock_hub_configuration.configure_mock(**hub_config) yield mock_hub_configuration @@ -95,10 +97,12 @@ async def mock_hub_status( request: pytest.FixtureRequest, hass: HomeAssistant ) -> AsyncGenerator[AsyncMock]: """Override WebControlPro._getStatus with a param fixture file.""" + hub_status = await async_load_json_object_fixture(hass, request.param, DOMAIN) with patch( "wmspro.webcontrol.WebControlPro._getStatus", - return_value=await async_load_json_object_fixture(hass, request.param, DOMAIN), + return_value=hub_status, ) as mock_hub_status: + mock_hub_status.configure_mock(**hub_status) yield mock_hub_status diff --git a/tests/components/wmspro/fixtures/config_prod_light_switch.json b/tests/components/wmspro/fixtures/config_prod_light_switch.json new file mode 100644 index 000000000000..c95e136f6573 --- /dev/null +++ b/tests/components/wmspro/fixtures/config_prod_light_switch.json @@ -0,0 +1,32 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 97358, + "animationType": 6, + "names": ["Licht", "", "", ""], + "actions": [ + { + "id": 20, + "actionType": 4, + "actionDescription": 6 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 19239, + "name": "Terrasse", + "destinations": [97358], + "scenes": [] + } + ], + "scenes": [] +} diff --git a/tests/components/wmspro/fixtures/status_prod_light_switch.json b/tests/components/wmspro/fixtures/status_prod_light_switch.json new file mode 100644 index 000000000000..766b73f766e5 --- /dev/null +++ b/tests/components/wmspro/fixtures/status_prod_light_switch.json @@ -0,0 +1,22 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 97358, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 20, + "value": { + "onOffState": false + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/test_button.py b/tests/components/wmspro/test_button.py index dc89d834e9d1..7a052c89d3dc 100644 --- a/tests/components/wmspro/test_button.py +++ b/tests/components/wmspro/test_button.py @@ -33,14 +33,13 @@ async def test_button_update( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, - mock_action_call: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test that a button entity is created and updated correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("button.terrasse_markise_identify") assert entity is not None @@ -63,12 +62,16 @@ async def test_button_press( """Test that a button entity is pressed correctly.""" assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) entity = hass.states.get("button.terrasse_markise_identify") assert entity is not None before_state = entity.state @@ -83,7 +86,8 @@ async def test_button_press( entity = hass.states.get("button.terrasse_markise_identify") assert entity is not None assert entity.state != before_state - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -114,7 +118,6 @@ async def test_button_rotation_reset_press( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, - mock_action_call: AsyncMock, freezer: FrozenDateTimeFactory, button_entity_id: str, range_entity_id: str, @@ -125,7 +128,7 @@ async def test_button_rotation_reset_press( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(range_entity_id) assert entity is not None diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index 58f443f0898a..c47c6eef7c32 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -46,6 +46,7 @@ async def test_cover_device( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, + freezer: FrozenDateTimeFactory, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -53,7 +54,7 @@ async def test_cover_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "58717")}) assert device_entry is not None @@ -78,47 +79,65 @@ async def test_cover_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("cover.terrasse_markise") assert entity is not None assert entity == snapshot + before_status = len(mock_hub_status.mock_calls) + # Move time to next update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) >= 3 + assert len(mock_hub_status.mock_calls) == before_status + 1 @pytest.mark.parametrize( - ("mock_hub_configuration", "mock_hub_status", "entity_id"), + ( + "mock_hub_configuration", + "mock_hub_status", + "entity_id", + "num_action", + "num_action_list", + ), [ ( "config_prod_awning_dimmer.json", "status_prod_awning.json", "cover.terrasse_markise", + 1, + 0, ), ( "config_prod_awning_valance.json", "status_prod_valance.json", "cover.raum_0_markise_2", + 1, + 0, ), ( "config_prod_roller_shutter.json", "status_prod_roller_shutter.json", "cover.wohnbereich_wohnebene_alle", + 1, + 0, ), ( "config_prod_slat_drive.json", "status_prod_slat_drive.json", "cover.terrasse_lamellen", + 1, + 0, ), ( "config_prod_slat_rotate.json", "status_prod_slat_rotate.json", "cover.zonwering_begane_grond_keuken_alle", + 2, + 1, ), ], indirect=["mock_hub_configuration", "mock_hub_status"], @@ -132,12 +151,14 @@ async def test_cover_open_and_close( mock_action_call: AsyncMock, mock_action_list_call: AsyncMock, entity_id: str, + num_action: int, + num_action_list: int, ) -> None: """Test that a cover entity is opened and closed correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -148,7 +169,9 @@ async def test_cover_open_and_close( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -161,13 +184,20 @@ async def test_cover_open_and_close( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 100 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -180,7 +210,12 @@ async def test_cover_open_and_close( assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes[ATTR_CURRENT_POSITION] == 0 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) @pytest.mark.parametrize( @@ -227,7 +262,7 @@ async def test_cover_open_to_pos( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -238,7 +273,8 @@ async def test_cover_open_to_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -251,7 +287,8 @@ async def test_cover_open_to_pos( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 50 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -298,7 +335,7 @@ async def test_cover_open_and_stop( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -309,7 +346,8 @@ async def test_cover_open_and_stop( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -322,13 +360,15 @@ async def test_cover_open_and_stop( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 80 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -341,7 +381,8 @@ async def test_cover_open_and_stop( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 80 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -362,14 +403,13 @@ async def test_cover_tilt_open_and_close( mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, mock_action_call: AsyncMock, - mock_action_list_call: AsyncMock, entity_id: str, ) -> None: """Test that a cover entity is tilted open and closed correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) await hass.async_block_till_done(wait_background_tasks=True) @@ -381,7 +421,8 @@ async def test_cover_tilt_open_and_close( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -393,13 +434,15 @@ async def test_cover_tilt_open_and_close( entity = hass.states.get(entity_id) assert entity is not None assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -411,7 +454,8 @@ async def test_cover_tilt_open_and_close( entity = hass.states.get(entity_id) assert entity is not None assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 50 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -432,14 +476,13 @@ async def test_cover_tilt_to_pos( mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, mock_action_call: AsyncMock, - mock_action_list_call: AsyncMock, entity_id: str, ) -> None: """Test that a cover entity is tilted to correct position.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) await hass.async_block_till_done(wait_background_tasks=True) @@ -451,7 +494,8 @@ async def test_cover_tilt_to_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -463,16 +507,25 @@ async def test_cover_tilt_to_pos( entity = hass.states.get(entity_id) assert entity is not None assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 100 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( - ("mock_hub_configuration", "mock_hub_status", "entity_id"), + ( + "mock_hub_configuration", + "mock_hub_status", + "entity_id", + "num_action", + "num_action_list", + ), [ ( "config_prod_slat_rotate.json", "status_prod_slat_rotate.json", "cover.zonwering_begane_grond_keuken_alle", + 2, + 1, ), ], indirect=["mock_hub_configuration", "mock_hub_status"], @@ -486,12 +539,14 @@ async def test_cover_tilt_with_open_and_close_pos( mock_action_call: AsyncMock, mock_action_list_call: AsyncMock, entity_id: str, + num_action: int, + num_action_list: int, ) -> None: """Test that a cover entity is tilted to correct position.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) await hass.async_block_till_done(wait_background_tasks=True) @@ -505,7 +560,9 @@ async def test_cover_tilt_with_open_and_close_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -519,13 +576,20 @@ async def test_cover_tilt_with_open_and_close_pos( assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 100 assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 100 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -539,4 +603,9 @@ async def test_cover_tilt_with_open_and_close_pos( assert entity.state == STATE_CLOSED assert entity.attributes[ATTR_CURRENT_POSITION] == 0 assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py index 9ead9e23b3d2..948dfaf3a1e9 100644 --- a/tests/components/wmspro/test_init.py +++ b/tests/components/wmspro/test_init.py @@ -56,6 +56,8 @@ async def test_config_entry_persistent_storage( ) assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_refresh.mock_calls) == 1 assert config_dir.is_dir() # created during setup assert await unload_config_entry(hass, mock_config_entry) @@ -87,12 +89,12 @@ async def test_device_setup( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) > 0 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entries = device_registry.devices.get_devices_for_config_entry_id( mock_config_entry.entry_id ) - assert len(device_entries) > 1 + assert len(device_entries) > len(mock_hub_configuration.destinations) device_entries = list( filter( @@ -100,6 +102,6 @@ async def test_device_setup( device_entries, ) ) - assert len(device_entries) > 0 + assert len(device_entries) >= len(mock_hub_configuration.destinations) for device_entry in device_entries: assert device_entry == snapshot(name=f"device-{device_entry.serial_number}") diff --git a/tests/components/wmspro/test_light.py b/tests/components/wmspro/test_light.py index 0cfb9976e3db..cc91385e9664 100644 --- a/tests/components/wmspro/test_light.py +++ b/tests/components/wmspro/test_light.py @@ -42,7 +42,7 @@ async def test_light_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "97358")}) assert device_entry is not None @@ -67,24 +67,29 @@ async def test_light_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity == snapshot + before_status = len(mock_hub_status.mock_calls) + # Move time to next update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) >= 3 + assert len(mock_hub_status.mock_calls) == before_status + 2 @pytest.mark.parametrize( - ("mock_hub_configuration", "mock_hub_status"), - [("config_prod_awning_dimmer.json", "status_prod_dimmer.json")], - indirect=True, + ("mock_hub_configuration", "mock_hub_status", "target_brightness"), + [ + ("config_prod_awning_dimmer.json", "status_prod_dimmer.json", 1), + ("config_prod_light_switch.json", "status_prod_light_switch.json", None), + ], + indirect=["mock_hub_configuration", "mock_hub_status"], ) async def test_light_turn_on_and_off( hass: HomeAssistant, @@ -93,23 +98,25 @@ async def test_light_turn_on_and_off( mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + target_brightness: int | None, ) -> None: """Test that a light entity is turned on and off correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity.state == STATE_OFF - assert entity.attributes[ATTR_BRIGHTNESS] is None + assert entity.attributes.get(ATTR_BRIGHTNESS) is None with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -121,14 +128,16 @@ async def test_light_turn_on_and_off( entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity.state == STATE_ON - assert entity.attributes[ATTR_BRIGHTNESS] >= 1 - assert len(mock_hub_status.mock_calls) == before + assert entity.attributes.get(ATTR_BRIGHTNESS) == target_brightness + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -140,8 +149,9 @@ async def test_light_turn_on_and_off( entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity.state == STATE_OFF - assert entity.attributes[ATTR_BRIGHTNESS] is None - assert len(mock_hub_status.mock_calls) == before + assert entity.attributes.get(ATTR_BRIGHTNESS) is None + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -161,7 +171,7 @@ async def test_light_dimm_on_and_off( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("light.terrasse_licht") assert entity is not None @@ -172,7 +182,8 @@ async def test_light_dimm_on_and_off( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -185,13 +196,15 @@ async def test_light_dimm_on_and_off( assert entity is not None assert entity.state == STATE_ON assert entity.attributes[ATTR_BRIGHTNESS] >= 1 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -204,13 +217,15 @@ async def test_light_dimm_on_and_off( assert entity is not None assert entity.state == STATE_ON assert entity.attributes[ATTR_BRIGHTNESS] == 128 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -223,4 +238,5 @@ async def test_light_dimm_on_and_off( assert entity is not None assert entity.state == STATE_OFF assert entity.attributes[ATTR_BRIGHTNESS] is None - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 diff --git a/tests/components/wmspro/test_number.py b/tests/components/wmspro/test_number.py index b0a052804529..dbd283a1e781 100644 --- a/tests/components/wmspro/test_number.py +++ b/tests/components/wmspro/test_number.py @@ -47,26 +47,28 @@ async def test_number_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 7 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None assert entity == snapshot + before_status = len(mock_hub_status.mock_calls) + # Move time to next update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) >= 10 + assert len(mock_hub_status.mock_calls) == before_status + 28 @pytest.mark.parametrize( - ("entity_id", "initial_value", "target_value"), + ("entity_id", "initial_value", "target_value", "num_action"), [ - ("number.zonwering_begane_grond_keuken_alle_raw_rotation", "0", "80"), - ("number.zonwering_begane_grond_keuken_alle_minimum_rotation", "-75", "-50"), - ("number.zonwering_begane_grond_keuken_alle_maximum_rotation", "75", "100"), + ("number.zonwering_begane_grond_keuken_alle_raw_rotation", "0", "80", 1), + ("number.zonwering_begane_grond_keuken_alle_minimum_rotation", "-75", "-50", 0), + ("number.zonwering_begane_grond_keuken_alle_maximum_rotation", "75", "100", 0), ], ) @pytest.mark.parametrize( @@ -85,12 +87,13 @@ async def test_number_set_value( entity_id: str, initial_value: str, target_value: str, + num_action: int, ) -> None: """Test that a number entity is created and value set correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 7 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -100,7 +103,8 @@ async def test_number_set_value( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( NUMBER_DOMAIN, @@ -117,7 +121,8 @@ async def test_number_set_value( entity = hass.states.get(entity_id) assert entity is not None assert float(entity.state) == float(target_value) - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action @pytest.mark.parametrize( @@ -138,8 +143,6 @@ async def test_number_set_and_restore_value( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, - mock_action_call: AsyncMock, - freezer: FrozenDateTimeFactory, entity_id: str, initial_value: str, target_value: str, @@ -148,7 +151,7 @@ async def test_number_set_and_restore_value( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 7 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -190,7 +193,7 @@ async def test_number_update_handles_zero_value( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get( "number.zonwering_begane_grond_keuken_alle_minimum_rotation" diff --git a/tests/components/wmspro/test_scene.py b/tests/components/wmspro/test_scene.py index 8d3ebffbdf3b..7a65e39f1946 100644 --- a/tests/components/wmspro/test_scene.py +++ b/tests/components/wmspro/test_scene.py @@ -34,6 +34,7 @@ async def test_scene_room_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_dest_refresh.mock_calls) == 2 device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "42581")}) assert device_entry is not None @@ -58,6 +59,7 @@ async def test_scene_activate( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_dest_refresh.mock_calls) == 2 entity = hass.states.get("scene.raum_0_raum_0_gute_nacht") assert entity is not None diff --git a/tests/components/wmspro/test_services.py b/tests/components/wmspro/test_services.py index 9062087badee..0976a5f6d6f0 100644 --- a/tests/components/wmspro/test_services.py +++ b/tests/components/wmspro/test_services.py @@ -39,7 +39,7 @@ async def test_set_cover_position_and_tilt_service_is_registered( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) assert hass.services.has_service(DOMAIN, SERVICE_SET_COVER_POSITION_AND_TILT) @@ -69,7 +69,7 @@ async def test_set_cover_position_and_tilt_service_executes( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -128,7 +128,7 @@ async def test_set_cover_position_and_tilt_unsupported_entity_raises( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) before = len(mock_hub_status.mock_calls) diff --git a/tests/components/wmspro/test_switch.py b/tests/components/wmspro/test_switch.py index 2fe0a04c1c96..818c74818910 100644 --- a/tests/components/wmspro/test_switch.py +++ b/tests/components/wmspro/test_switch.py @@ -42,7 +42,7 @@ async def test_switch_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "499120")}) assert device_entry is not None @@ -67,7 +67,7 @@ async def test_switch_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None @@ -80,7 +80,7 @@ async def test_switch_update( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) > before + assert len(mock_hub_status.mock_calls) == before + 12 @pytest.mark.parametrize( @@ -100,7 +100,7 @@ async def test_switch_turn_on_and_off( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None @@ -110,7 +110,8 @@ async def test_switch_turn_on_and_off( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( SWITCH_DOMAIN, @@ -122,13 +123,15 @@ async def test_switch_turn_on_and_off( entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None assert entity.state == STATE_ON - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( SWITCH_DOMAIN, @@ -140,4 +143,5 @@ async def test_switch_turn_on_and_off( entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None assert entity.state == STATE_OFF - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 From 918e73994311ecf3b382e9fa9060eaa9b2c5d064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Mon, 13 Jul 2026 13:54:44 +0200 Subject: [PATCH 526/707] Bring nobo_hub to silver quality scale (#176388) --- homeassistant/components/nobo_hub/manifest.json | 2 +- homeassistant/components/nobo_hub/quality_scale.yaml | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index 13d7dfd60794..a098ec5a6607 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -15,6 +15,6 @@ "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["pynobo==1.9.0"] } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 72de24495175..8855f9f95240 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -41,11 +41,7 @@ rules: reauthentication-flow: status: exempt comment: The hub does not require authentication. - test-coverage: - status: done - comment: > - Investigate whether the `_spec_hub` helper in `test_init.py` can be - replaced by the conftest base mock. + test-coverage: done # Gold devices: @@ -80,6 +76,4 @@ rules: inject-websession: status: exempt comment: Integration uses a local TCP socket (via pynobo); no HTTP client is used. - strict-typing: - status: todo - comment: Requires release of pynobo 1.9.0 + strict-typing: todo From c9d6180c16cb2e0472b74e6d5bc838e19434a2d0 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:00:33 +0200 Subject: [PATCH 527/707] Featured integrations: update screenshot (#176376) --- .github/assets/screenshot-integrations.png | Bin 101607 -> 103978 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png index abbc0f76ff0288adc024528eb3ae0ef2bae41156..47357c6581cc7a3846f645ac9f1300761a3763bc 100644 GIT binary patch literal 103978 zcmZ6yWmp`+x-L3MAP@qKyK1_t>U*t%eo6_yN5n-0fk5v?MFeC)AXozs2%7#a90&xZ?73SHd;x3pUGh5! zR2A{=N#_ml9?DKum>+6!h{g+eL!>7nD+vO*l7m3M{6L@w;GqP%`#lT7YNoODs~_cEy>?MDCaz0N8o&rsK9r5=cVH`CwGj#$APsLry&hA{&z$l z#L$SCWq!7c1qEkM$>g$Dp+!{$X4*f!lm>o)#UKlm%|&z+i_C|m6g!0#;YW$&7i}VS zYUtn%2X9aHXxQQ<-5xm%)u*^Q-YkxDF>!K|7#JE-y@l=L`=~8;>oaP*2`Q?m!wyi2Jv{FzH|qQ;#xKXysCCONtan0Y+H0xET%)pFRoQA z4LpL(gJVj30*2Lls@r&H@$~QwM5Cc$9YpX64QmTm_b6?I@v6@D*&h)c8xha%>q=PS zc}rj7pd-!hi35}zhpe0rm=JSEV3UQ* zvVugEyNZ8lz27c=$^4~Xr~O{ie#olzrOg;?XR#Vm(KF z3e0CATeqL(T=XE?tKs|7OFpah*^@X0RReU>Ic2Q`v1Ae@bSmnK0*eT%ahg=&qz0)@ zDyj)F=HX4I3H}e?sxJxzHTL2pqf0+{{kduK_>4(5ZBU)(3T3}{5(WkvVxI7T=2;Y zG-L`V3N5O5>Xj#ES1_%JNGV4q`iIqHl9iL6+3zjGQ!7rR|Q`!|f?b=fm|;2&M$4kiI^tUjIV& zt-)|Sl=Z`v2|qtSsL}pFmca}U&9J6S%yp~(y}P?RNJLuN=Z(;DOIlW!K7APwn@J_USuj^!NEuB zOwjTSDkv!9k4#P$n=jW;X|g3Nkj}_Ana=MMdrdM*|IEl3ZMo9SZr-pbt*@Wk3<>z; zXLaSt^49MS@rRF3!>Rp*MzMl^kT<=|(B8o0>^6a2`tEUv{ppjmt!=e9+V$C4 z=i1tuj+(i7>2N6W+qc%rSoI0zknA*}1mTpW0}* zU6z|vbQ&5QwElC0@-VM={~d<Zy?-)= z!N1OlW6KxD_rgB%seUbJ`VOAOPPqfyZh=fnPbpSd%PZE~4({TfDZ_4ip2UxOLyA+< zF<>O*X^O5G5{CPYKi)uJRCgs%;N#QQ=(yW3){qI}*L2kv_Rw1&c$l-=8v{`S+ZZO zo-Tg-9*qH=nf;8F-yK}!QRR?Ei?4*U5=uVeU z)w!C1ez(zsRO03Hf|3^cO>%N{vy;B*{qCuM#AV$8_l2-qUjy#MVcozKP8@Q(JR3yPIerazC#13uUXGLh3}>Y78+G`i{=6kSpZi z23m2dh*Y+|m=Y^pL^Ca8R0WJ^s}T$hs5us4Bp1?NGMwt8)3#VrM_~k|ZjF3; zM=GgQ%VDowMiBN-{yl5zRlb6xZ;=qR&3WNRZ|fUvwniC}xl*lwMRTDFrxP;Q*G@yz zr_E0bp(KyQ?5&)ZwHl5PhjDyP0ED)fGWbhO#FZGY@j zX39D(5maq(e_g)&AtD~sKRzC+)lnk&2k#vF`@gXrQkl8%wg5FYb(+~^%C9d3Yr4*C zo@*_R68Um>G{?yb9I|URUp{)77=mSy!yQL=y4+~Av9W<2CX_Fc1id~rNo%L;b=|KL zDnz2Gsp+yiAWqk2FKi-}#OQy2SyMb^V`KBHEb26buKlN$#*n9l*ZmHb#eBMe1c@&0 z-?<3A!3iODTm4bs*?#zwK$^h$khFlTagN{r&b^KA+p4Zf%9f>1R7RNcq^IB^wpXR6AywVM+0826LyNr1aT|dS@Z~s7E`8KV6 z#i#0ccTo@#b=Nz+YmFy;0t4R$cu3{F-YH9{nIr2habQlzORGB>Ox2u_uY?esVb87X zO7xG3ShmX`df@3mLf+5pyzpv**1_Sk*v*t(2123B$p~m7OcJp10g_eaNpJv?U;tL! z?3|n;wFX+Ha&?{S!&$D!F2w`{gj;bc+Nr$n zViTrR92FI>D@bLKk_j%*ogK6NBB6s!qUtxCIBh)Y*zi#r+S?lU`w-`7=67HGIlFev zECcmPLXt+9f+DSsc2_8J>2#!p{79SM7U23Klik)zv$;~J^@D;8#|Kys4-Y6X^AB#< zD5I(gA%QxnyvMz3JHI^$J&@eD*VoX2@0-jhhi$$?xMQW7Am-sL|*5Z4G z<)SbFA{uBN)VJ?~m1>)r1U^XR=pdRlIbPQWB-`OKJdbv~J4X!RV+-R03e)>+`19bP z$~NbE@uZ}rU}Is_dMpYH87_pAQfDf}tbpp`^o`pC4a>nH41cHqrbY0d>AVBY_VS-O z+4A{x(boe!gX12j)mKP+?>q?;G{N%DkRp1V8cvBj4_Al#6>WkSq4`!_TSmdG9#1ta zXA|=Y}{dl1s1Ob(XcOWPJfw29jJb)G??jevHS& zV$L90<5IbLBd6qEW-BVUypKaB$0kQ1+4PHXeeJ1sElh}dPT0R-nYbFMdv=2Dt`+Al z{4_GEF8@H)^VIBzPd~CuC0FR_J1G_v*K5sol2j7k&HkWB>$)&-h*SqJBu?w7t8(oK z2CH-l)3HP7%Z9~|TTRSpgG~*HoRwUI*v0^82|7Dp5-FHYo@&wmg!E^_(|M*WIPYa^ zP{Hgo+06I7DwyFSmgU3sF`$KQ6m=^8rOsyKnB2J2$Y*;L^*wTRIK!S7CAB^^}y zSG0s}i;A~{_4v{GWF|xri9V~0iYUHGJ8s6E+~dq3KCX~DERav#Mc|!>Pux+**6RTO zFkCTiQw`n!WcmYum>TGf1C+->GRmx~TDsxB(Fnnq2dXq^iWq&+(U%_H5_MDbvtoL^ z&0VmHtzzP@#sI#ouyhhVk7e>4(u}di>}r`(=%b{x2M-s$e*HW)>hD4QUKLL4IB1B9 z<|f0zrt32lh1Yns7IewGIHCJ>mjDCwwh%)_8B^&PDdooaEVh|+#CJX+XnpYJ4PiV{ z-1$RN=79y`c$K-0Zw3q2zRc*80+ zovVfoZ72gXGnef$rPDa#CUb<*F)(^_MPp_lzAJ5OjrwMLP*D_);z#*quS$!!YS94`*q~uF*I>}5%$Y0g!^#P~{^7Hqf zEZ5*%b=e~zCMHf~wIUSCiAv*iT8*04H#B54o*-KFdi4nKm@beuT50C=`pyeX(qH+K zDb+w7A-av_yD>Yz0b5NM$x###9K61qzP^3;4kpuTt-ZX6J4jo0fficF8^DN`*0}#> z9$R1oDr~kh8c+_6KU^2-{Y8Ul@BgB~h=>RZcJ@Uw6E|n)_0!YSO34My=K2svTphQn z>5qLe!2I=*4K-O71;C*U!{p&bm7L7}b@{hZ-bx%SJ>(XeHq?GpNh9q!ZYl`Ewt1BwrD9feK-s$4b{5mb8)A*k zsn)dK4JGYmmR^#t$cWJ<4XfocwrITaB3;r8^>7Xh;qeX5 zvkQ9&b<;fr5BUg>uD6kNMJs%fww<^usKmAPPYOHRUhEO{1{Eqd z`-CMSNQ+iESV1N*X##F*f7ISweUWktz=8;`yD92mtChx$=*@%0lQdiQz!KfRcnaZ~ z;zPL~ljpeetj!^hx{`)t`~{uIN#%=t8K0DT(}C=Od?bCr@barG%sjiK`@p9{v`O?f z&G92TWPyrl2KyZb2_4A2evI9Fxzp!UuVtrK4^tc1dZ@QRqbl|GMdH5e$FCKAEr#XA zeoP2{Vp*_U*pWLTE#$GrG_(8Z7g7aP7Gohd`+k_u(W0Tlt_^N$d;Ygv_HJ96W0|aM z^nr+&)EDC~9#RDsN^z|(PRz6uCc)@OEDq%69jinv0S>YY%{)wf0db%91 zSC!8Z5|kR}nGTyZH5f@`0M>%;9t#UGR;yKgU}->Rnx;{!4+So_%gO2+Xu@pyIF`m~ zveI0aZ;mBJVvglq$p_mFUB>Yb7oxXKJGGp`>bM<%X;H3Zin@Ewau+pi^%6D^FSuB>=&Qn^sU0N;7kxq?_X)KKH zS@gV8=Y#^_&Dq_MAICsn5DO(Cb(#0|$ro6Mw*1?l8+qN7esO*tjqSP)M6n33*+G|3 zD_}Gkj3Kj)I_{ILo@9wO+N~a4qD}X7Yj1CMKUXAwX;;CrpPxk{tT<4|l!khEj`wE# zGsqe+dN;xtb(k&IM-sv1Go-%>tl08ps)8^yP{x&gVyRv zi9abq32CV--;a@F2#oOiH(2YX40dnpMF%$L+l}NVRwy)%>C+Sfpw5=gf&i>d8%9-G z>ZHMQD({;&KDXUNfKc92*xgZ0LSzE>#OlKOEWD|`!lVR zSR7BiU>bZnI=pc~gbL`^hCbE3Zc`q@JPffVP* zid*Gencub&N=N`|6aV{IDu*caG2(>Vfvw#)jOC=CQ)rZFa6~vDccaGy(hS+7{am+X zlI~$Q@A|9m1?SstA=T8HiGzh%ZM4)gT6VKy^QI>y!f(vStw)fC$iDz0DNWEY6+^wo zxT+LAxV_<2N1Dp5aa@M5kfiEVeN`hf-I#_w5b0O6Ww+}&G}w*Cp(SQ7wEs`?*_WQ^ z$Zl%yS)XI2PkFW=+u39mRA>xAeyPCz;#+<#N&++W=6nIT&S8?A&S%3Avs3W~O&zCT ziSMwdwy7T(i{n1e*TXv+eZbq~*}JcF+JMmS%nO+^daZd$1xWq==wRV@-wv+1*l@W ze*G!^ZjD&6&VI=PH5>rxZHc8f$(Hcb!}@_0V9l{A(wn28x^vA8VT~}k?1F4EkEK{Y7Bgoq{$akLF&)EJuJr)a z2QIu8wKt-z;2(R6V2Ja`t&j{N)&~RxL0S5a=-nIlam&-+Km1YjCpOxf*?Se5^}yn} zMrT^0l_Xu=2l8rDC`$1zMe%0D*h+>^R`aFEIRT2-erAH9wvUPl-!{(jJ`x~+hg%_n zsQo@UY+(pt*0?$?l^P4Qn%_UXh0P28J6G2`ecrrVre+3kw_$%M&f&u_UB}2&o_J7b zsC+^D!AcA3kKaf(cj+G0!Ew||vROVBtq+1B)#;w@H;YDj=}B_=5*zbcUON{%!&gW1 z5r8J7$BZi~C1p5MC?k=?D9By!dVP3ze-CKR?*o{Rr(a@WznRYb^di|SOal-)iE#J_ zEUW=Qc7BV5G;;ln$7=Zj0|S;`43jp$Wdl_CJaAeEPQN7*XnC#Hy@x4Narw7f) zckn2LBTSzXzN*C^xr>DW?J^ns!GMN_wv`lc+a1BdWic;&aJIK+R;xFMGT$Oz8t+So zpXc z!yOJ7X@PZFnYWo_+KNFTqtx_Z+1ZO7|Niqon~08o1` z%g>X-2{_SEQEle$T0Z>eu4``Ahp_xx9p7dF3u_)Vv#6+OGRw{3?2}gA-(}SwvQ(!r z;zRV|t-!1(@cQ>CAATF>Fb)EK80-Jp!Q z^VMaVB7^zK25wsoQw+@*yT4GW1`9+xdWmk9MgT$d+;=IRLT@_XG^5zC76x$^jr?lDFTCo$l2&co;!>W&{P{tegF(7t;Nw`cdzD~j@x(s zOY;V)zcM!YCAU#a&qdVmz24j>WH1KCF6HISwNpyla2$!!iHNbfNlQV2-IjonzJBAZ z5k^T;Wc+Qke)RX~$jbM|WTMe!t|Y^U&7`8oT8b!nlT-yB{X6`Z4#&9t8I^5@Cs8Wi zxx8@D^mn}9?whsrB-!1EKcbwCx3`Hz_mY%>t;uM^3lsO%2fiT{4{*fY@iUl7O9taz@!aF&`PC!tPo*VK-K93J$1oL4RM03qQZ|Cb>~1XFN4)D@Gdg3n2=NRo5!a<2TH( z9f=&N=iR1yuj_mJW+zA{rMFkbq8n+d*r1)3qs)Eg4!rt;TbHA60u%k?ocZY6(d&rS z{LS7212JP@D(rtK6A=-qmXv1+Z}&;4!10Ypn%nZh4~JQ}m1B+Nc-`a4Dngr#8BcES zR}wj8XMn#ORZF`yJ!nIErQ!)1eTb|8D1LWXRHID!bu5F1xn601bbtJ z>jkgR0=jq7{QG(v~W}g8BClncs8Aw;Wxo{a4O7$n0bp<0fx|@dAydIXg^fY zM9^#zF@E)#x--1CDR|a+EBV<+Z_UzcrVJB|DLC})+22c>>57BfNcwcZv`)5V^?4-u z1+~r9abCWCkAk^cbGKxq0-rcBAl)l40jsUl5}khw*Nd0^>#x0!t}x*i3>+y_AMRgN z+*>G`MvVL>-U4e`_YGQu#ud_kK_QDjIWe&huu#KH)R`%vp`k5%W&^eX^^FrSKod{f zM^ji~wv+jDYVThjPoV~iBs^d4jXGKKg5&>IA<*jTDkwKEuc#eO*6#!^Z}j-sg!~ef26&oU1vUCZ(P%Z3Ck39Ko)YO?NdWA>_*qYj z*XDMcuQw2mTz|Dc>C5BsKyR_c3>YWRkGJrh^YaORxxm`!%R?`4{2f4}L+}Lx`?deo z_y4cbuz-zWp}mQ;vKUfE4Q>SdAl;5SEsm(uWp81jWgO7n!t($9KJ%w-7!LlYPZc)^ z6KI5lgm;gRSk9pUPVi)1)Qtb|Yz&-t!)W}k#6Wxh1Ncjg_L>C%3yv~CP@w?73^P`b z-Do*IzRpW4d>(L?v^1JHt$dJ2O}`k{#wACnpo9WE4+rH}duP_)j&9gFT|ZLb)M#?= z?5VHl$K%k?o%10YULG1#Lem!KCRf#|Wb_TMbYA80!|xp(Jiu%e4V_$TB8DNF18dS=#Trp1p(WOI7Q;!M7<}fWWT%2ynecvhor`@AM4FZp; z)v!=?LZ;2@WV@Kv1z5@hwU7CAwbm}~=@SCQMP(uf_Xn=~&b)qHaPjoDQ1j$FI6`g)K>T2YFwqdcA*=@Mm6dPU1cQg ze<=KcV<3QI&^rJlL4uUrWQ|TP0m2`!xCivezXl92E)IWUVbN*Ut_%v1c)i?H08pbP zUtl+oPoky&3DJIe{{U6tI*M0wBWZ+#wS;_PRmO+2M!%XDV$;sUFu|#yN|C zE>j_yO!XzzlPj5{4zg^@sPry1J-NgNI#G{S0$Fg%#4hHa&>kaYqHpL(u4hvTZ7Fp$ z@50h{E5^2a4~9WpGc>;}Y648=9OdSR5?LW#H?@4OW`~~P;Sd0kUL2NGG++C9c+?Pnq73$# zG~Vv^e`kNZAd^-@QNeBr2eYDw(-fC=A!%@$7$4cukuG_ZUmc z=)GaoL{B8c`?D5KgTn@V42n6tF74lmf3uQJC7LjN5n@<4k&$Qq$auTrh)T@3lkL@UBiCte!v8>Tdhtec~dHAG9V zij~|7+uyEn#2=FZ@z3hJtfqAbX)IydLt#cm(hCoec&NZ=c7$G^5pP4#wHE|OHA9N! z+P9bP=0#KKLyZ#JhsI%yOr-UZS7qhBCx_AOP8{&x2n;~|n*ENy3P7N?j^A z*dsc_DFUG{*>*V1@7lrQ{I?44jn6+aDwg76CWeQs?E>AHz0QCi+sq#dx#(xM2pUOX zXmBGG+eX8NrzoP*QMh{gXe1;P%Sb}6ijjQ95AB^h)7s#q%!%;}!W|#X5gs$BHOn{Y zZ3OwF+)ABuS*%~ep@7^yXrdOe{G4Gj_u?o2i8wtY-K?eL4P2^wv;9|a8_sH}b=#rp zS2LFdoseBvazGOqm$vB@5huNci~x?LbOpCrV}ttzo_3-Qx|cW>1$&TSSf@!6&1 z>S5gLSGyY(;u)3vr`R#75%sN>;`pLROrc6)MsT?T1RufH+;#Yd7nZ^VD~@d}0{N{0 zy`vI&$_g0~QYK!tcgG6(sk3Bd2$J6O!X&dfsenma=oQhcmS*2NJ9M+XpOj?iiKD{e zC)AaV7@Dmd_0tyi4l!E8kJKWz7{`;Bqx9CRI&#ZJ0gsretV#-^uOyGgXpWUzRpGd1 zJh-}`7C#BGKro>IRr-;d_0x*_Oe>jtPB8xb!%8(x6`cz*#0bGzG!?8)-cU6U_Z?}_ z!?p+voh^y!X`T-$0bxH5;=(OAz|ciVun2tH3Uly<3UUm4R_5IeI)+*z*sA7i`=Wa} zc~FY<38zpaO2wgX#El{8uz-d*11AR&>6fJ&`{=@r_Ah~9KG87K<5--mD2&qJS;xJk zXd)i+Tlk+C)FMmx1zLb(49XXB+~Kubqj273K9moICyhl(7k12I;~>cRy;h*~CO>nv zH9;cyK}xQ`t(M@UbZ8Zud_uhD&=)&m5{(zxntDi(j4_TeI&RD06NjwuZBo5^L{q*s zx597~l1MbHs1YK~2APV`lyOSE!Vp?=Ywy5ew^Mql`8tmVrF25RyrZ}~XeJTPO2Z7|4$qL_;r zJIK<$Sii!zAuXXLHEV{WN_x%~HXI$9td~7MG8t(lRR`ut`uxBgQoZ#UT`G1`!dK;hQWRv~R4iEP9R?Kl*yJZ_tfE z&D&3D-e~$gnvBOHH>b7VRHW*2dTFR#snekYeVymV@A?vXmInvpqN>|9zwHfw*}OhT zBDtG;%YnvWU+Fs~)#2&>b;}iHUnR8D1|<0%LiA2nLeK;A!jJ3Kbeaz5e8W z_8nw6mYQ_505Bl4&EH(WCEMHET~0WGX0rB_(MD2OBf5OyFGrZhx7|<-_u`Z^q1KO8 zz@Yubaihs>!KSHpmrKK;AmE}N7o&v$K*fupG+XimxDS)b6e++_@-6Xr+#8$CtCa6{ zQ>+#`ym$d4a0~D?0F^LL9-jo@V3czC6S<=3Ku!k8AOkL7GOHDGXDp@MLY1Cy`K5ZT zF=|EIjcO6wpJs<6dfYI44to)YaKMCuvEJzRSI_?N;REPbLIT!G^A7jdf#oK<%T`NN zR8+F$Te{XW=*Js~9&l%na2R2Kk%`BHtWTEN;WLtXy?7K$%3i1NtXiQuf!;u!-Q3Vt z-*NN+?c!hb7&A|mDACQ8s%%*=SL*cK9oLLEIvgpcn{eGM=t-ood|f-T2GmB-SO)Lu zgl==$+5jbr)r~!)buz!xI;f0HOe)RxlmH3?pf(gBhYA2PCcush0DBFIkSh^LHAJVT zN~o&hM({j*;dDCDf4Vy-I}u1vPxtflvp$$21CZ26lii-YcW8LHA@F5xZtg^x8f&>$ zhg4rMCKO;v?E(Wq2QXJjNlDlD_LOv40GJ0**1y8-!_|QVkkb(U z+V%Jb0zSW~cgT|xANC5J`w@U_^YY|kI$OMBy+Y`+jS7HEA%F~vmDYM~_+uHbG=GbV ziYnFWNC)!8pq~X&1UxTydipB;rge-!RAeljdl>$cJD-pc9C=I-kjap1vNZsZsw8kn zZKB3cd;#lo$de5{qDW(`i_%@MKR|0T%in%Z`OJd6CkPp{Qbt9PVQNM#L%uC z=P&ku2d*=5ODd+-Xm@WK`5g)z@wj5zVjKfpoh zzx`F1XzT;yyWq4>v~!WvDQ@O|aX{1Ep&~Id)RtUJ2QkFeD+wE|TWJYq&Cqxp(E(>% zzUZs$Gdet{XSyXin`eW~>GsR^voq@`_zR3?4W>nt*rRxqY0y?wa~bL%9jLC^g0>Hu z4?G1(?NS1-k7O{w(@&lad$83tF4Ospc7z#T-!ei&kw%wpxAs3>E~n#Wh!tY|pUe-V!`vvPYNDY*xgxI>?@)3ov7@* z(g)%#D&Y6*&o#Xx;BhpRizIsnfmcJ0f@ z)>=M0{+O7U1V-g6BeZt9h21t!+GA);Esi2lHNB5q19lhF~3O{u0ix$ZIWSS;khnt-Gk6dVMny>mE= zJQ?oc1_^lja9@di}T`Z@v`d_(SCFzLY~m^h-bWQweIg22X%J z>tKtfE@z7)nl5yOgR`P`T?`hqF?Vvc79hESNC`|TIa-lUA7$JEA(@ywEEc=XbN^na zC-1v(UTs9PjO(FVUHmqj3I?@rQ5(}dNn#^7Y9yzeIYK4G(MAL;2w%b=ItggD(4f>R6W7Y#zA&PIYb`pd>JF zal!dg012hb2`|-c7Zk@a)tjh49eD~~SRO7hZF7f&}w&p=ReKD>B+%BkJyIzKILO_ry855dL z)$-i|@6z`p6*myQf8E|B-EGsiX&|~k6A@5A0R#-*5Wa%xI@sKW-|0uFdp>wF{LM|> z;mhB$x}C{LE-W9UNtxnFG@?nxj7iRT49pSgC3-5S^T%*6O+wpfJc;0|gtha zGe*m-SOPiLrKs0=ZNj5jw8H^XSG^METQ9QL>2BEEhDg(fewePutRWw(F9K$-_7~;k z#!i56d!(brMa5wnhc6&RqoKzzt8}+F-AKYSRH>Fqm~x3~+?JKMy@=MKp%t~Wc>j^B z&^2bq{V`rz=q}KLt*HWzVXjHa!aaP*jCq$%*C|)9K#Ud8V@@4i+9<7m3?1`n&EvJE zr8iIc&K_qSo4C~zO(Kg zvlAj7L~;s9No=1NWW3R~>y1dRbeRkZnQ_FH3#J?|+*t}TJG{6Wp8ke4s$W67@~|w@ zKbEeq&MP1$#!P)}ZtXLR)G ztkn4v56|~2vwJ+DHmogGdpM%&p6`I|zJHH-f8us~fWav1kV|%Szmu`7kn<9uxobhbL^+k1a+9Xq=(J5MvbWr&{>>u!vy*Sn4W8JfQ>6z zUl1vzQ-4N?N{CdZ4NPP)n=K1i+|7!SUXJ_&%H@W##>?iQM7KhKMxu!qVl848UsnO)rN;zf>F7)-a<4>AWfquZ> zDl~u-Bz07z(_NR9W_4CH@Yy{>YipT-BH(;5s}xBZWSM48_A>#QAWB~aLK0)iXhQDO zWU~baEFa4C%X0~P^Pk#>9Y)|k1+WWn1nc$z{%FkFqd*L6*ZJ20sQr5z=~0Do^P3))Ep8C`_@46{uIeh*l zuF|6PjyI?a9Fd7>4E&g4zc^spxA+3&Xp--kYlzz}x$?!>nU3<6gm0~@#XaSat|P;O zPM4a?P8ZrWR_Z;sPG?HAv@YwlfF1%cbM`tx3%~dAG<-HS4W@%I@s#KB=Uak)C+*kb zXJjxT?$ZZuo`cV+GnlAhS}?!I!Zz+hTAH5Zr1jh0gyjAl0|%eB{Wv#0&l2~lkQi08 z22dj4$|KtU{AW#+e+BnB1F$Gl0QE1i`7ccRCyB+{L=|baaZKk+!gPW;k8qv<5N^EO zSd}l;>U6p)$Z;|$d`U?BnQXvK0k=!F)#W`m&@I_;xJ!$6xypq0EV;ONyD>{t|Ig@}F8+a%V zBoj6O#!>7AGGD0l2FOq05wLmS%cPxJovA|kXB{BhAOx6Dv)3Cv0VTtZYVknUfx7H3 zwfY5wE)xMvO#dH!7hrP=5mn>II?TA27-h$ewM^*&0r0`ug?d?ryaz$(6c>}iCwLn) zK$ijh3v5IMEz}rAG+X@#>CbN50er|Bh@b&?!@r_}?&al$l!5{s2WP0(WE$vd=lD2F z@*}`5XKd`mQi>HyY$3@O#(sckDqE)d?dD`<7zTw4#0Suq7;?#MAX1v#;zFm<+}rN) z$Y#C%E7=R!ECGxqV6z@6;I^(k=LkpiYt#R2keGzTWjjiEe4TdC0(w(M z*L&u=WyE}c7|~u%P&3V5tKsqX|EQR*9i`ON;InZbQ2yRUVTmc*XSnq#}BcPZDcJY8?;30xrJRxJor9rAUzCfaYYJLk7L)+#T&Jc=4D7HO)6$2U#aa#;h3za>ce z7H~dIb9`nTv*zd$P_M>m8>@0DNRCKPl3t5Bx~nFl|MLYrk}!oNSMK3?31o1|vXE&p z5<7O=yL{i9L7lyHhq<>J#~7A~=LZOBv)gm5Jul{9ZDl)&fE}U%Z>)tQ+vXwsQ`0Vg zI;w=Dw~NjSracFamT!0xgvyLl7$Y#SYT{r(BHrmd^R-zb3l11C(o&%H-P2DS2U$js z8_MbU=W)%iMRQ@3LrZS7GVUepBejh6kX4Ey*w)%1TAybpn`^8 zm-QV*-be<$i-t@y{0v}JO~4#lxLtf>-_bqzkvO?Yj*)RxyNdof?8Mo1-^F%@tNxTX z=blRLp;G1kG?w&F9=l=)Bl*|PR{ff?_XXWGP8M}TMcLVF)zg9;mLolJAZIrpV6Wp9GV2HY0clfh_f4w7{?d&XbL( z%4V}kib9z^Yx#W+e!T~3SE!G z;V(I%S0;tR$vXVWI^CHGKZS6Hun!hsPM-r-yfzN!D=KK6_9s5CZB@TR`URxUAHx!I zTYvb1Cl|eXJmvlSbcb9UGxeJrA}^M>xV5~igtYoGE)-u1%+JuGLaE*JiAvcqAZ3qr zvpcSWtkStSG;QFY|`=upVa!werFeG6o zVcNKiS6f`BnxGy24CZpz%Jf}m<*>k9D+A{sfJ24b`|VFl7IAfG5)YB8=L;euf4X~2 z)>-(S#DzQC8zZk-E0s#DmBG*jpss6`$=w@W0%{+<1Q z82jp|s=l^cP!RzU=>`!=rMsoOk&==)ba!LWB_$lXkuK?!lsYs>gLHS-UFR3y``!2c zbH{KXa5j6dwf2f<&1cTJetT5N`YrWw4YiC(>-0xIC*-T-Q)e25st7+_UsUhCc8h~@ zRXB%luWaqU{`C$8tf&7AGy7jlFx2b2RwcEb-n5Tomhb%?(w?H6+@lw-UOn;yqG$8F zm+AnoljBwlD!_D;`Q1ktT&5Sy$N{yf{S2Uytj0mb zZ1aFzo4h^ivh#cC0ITq}7Q|Eq4rD*UvN?+B)JydSPBuqLC8$Y+eE6-We|!MYzp=6L z_Zeaii!XYmrhg;Kvm7s%M*ykftZ+>S=(8KSpM0#i-f_CE)eF5nOrl9lupJMv<7WOzqP|(+VwWgdK}B7uViejsJXh&P_?Q;{p1nnCM^O7;Dgf5ZUn@{^8A| zGzsy4=mC}giTlJ}!9zd*-M+ApgMS4-yi9#>bKGI4UfB^6v zCU8FMD7k=t$#|r@((rbr;g_rM@Ug&xARCZ60TP?!N;-B&XXn!XzJAeukk?<=y?|eu z?sl2FZ$urn1dD+}wRZmdGsdg_zl%XT8e{-x7Jw2wf&5i4kUR2kenVJ@(5?D{T6{?& z_fw!kZare+x6i#~e+<8|G~X>yS0aP%o9jOjD<3R zRXSow@#Ws8LuAC>xl~)FZYxk0KfA8v)h(`vn2c9Fy*VkXiOc(y&S%_h?vLTy z<2&*#(qqdE^M#kD-Fdp^0Ifb!dW?0IhUMvm)L=0dp{k5#5f*Qql>%ydodM)TnXQL0 zA=Yvi7tONt_ct2k?e8Wy{+}v^CddlOkuTa?VoT^@bsEdohImbWJxYNW%TD=sI zTD1u2r$^D0XtXP@{%m1)7LF%MijZ%rpP%0!@h+kSy{fVo)IvY7#>_Ytj7sR9 z>U*uXRC22+#yL#mVl!NODnFFcI5HO^J-?E5b#BK0VyF;!FfA1gn+R;xh|I7+`uYk- zT)3XJ=Y6sIh!OTzToh@QGBuk|v$klxdxKxkeJo^U5d-w`?i7Z}*>WHr4udp~wH%k@ zUXholZjN|+zOm%U{ftNZ@h3*PH%GeI<4r@Nb^cxNnU1w${)m&!SG3{FTXSR@(VQiHcdM?4Y=UvES6N$(BVcCro z@-geHX9jCk{12dtNgMf#*(UN{uiExU*=~Z69IJh}S~|488xM`vf3n+Z`$RBk*x^MO z^2B2H zDC(UdRff98DaanJpFXM8F@{iK^K3p)iF-^N@`-~3GbK7m2Nsk`YnVl`u-p%1dE(i9fGD>rcR|}WEXE1)of`a?yeLl9@Wn@)VD~tN&`Xu{w**MNQIjZ{E&%O0AaWJ)z6YAE_yIcJ~A_^-hHSbTN zrPTF7p#Yol6g)55A1`BG3o6W*BFi%SqtBm|WVjpR;k#bKvuS1_luJ2Ka^IE>P2_80 z=j3E)Hrl{H=NL6VIz^7bwdJwZxV@QrBhi(E5ZwI9=$@?Kc}w<0fyyxrieO7HJS-Ca ze0WVFQ=t5}*&T~Kx|3kzRzB&>!`@8ubWh~;%SVO}oEF~7soNSoU`P7?8dLK@FCr2B z5jQou6Nar+1hN2<9ryLb4Htbe0cOR|_GBTptPndS%_t8YlpnJRR&IGocS`G68Nt4X z{X=yetWfu5DO4dzUEjVF=&R09Pt^q7Qa2(Za;ETc+xKohAt@XD?0kV2?O~Inw(nH^ z`AXa)2naQNHY_RL5Z&8>IK6D-;hI}~blJ9(s^aNL$zmQ+q%NwF{+c~!NS=tg z&~1?M#;*uis>fyT za$(_HpRxOVUm(x@Y=_dvfJvkMWQj(T=w6c5irT9w-9ziU-b6>=d6G^lJD=Bwbq6nx z-Eem4x$hQU>Pk>{xFr0mFFZ|z*rB?~ZAU_?Z#Z&m3}y#mL&SE45qt@`QW&hDI$D<>tg#nA zu7`>@X_@YXY(}m0Oo^9!XM-h5%R9KlNZn&vn)b(2X&x2y@b5_h#fD1}ob*Mq%HK_t z3RQ??+c~gyH=$nM*`Lc^VLvRuk$Yj!rM=Zx&zkX0&s?c3b{|5WvB-SCx&H>C!ZX4e z^Yu%Ubm_R0=brQ`n1pD^Fur94Gs5t<`Uwj&Z7np;0ZhY}ACeT*lK<>VQmNzU(8Y7Q zh6!-Q)3e7!q~ZkbZK5Xz;Q3DN=JTd+!n6q(oC#{!NRiD9Ar7JEuZ0eb2Yj`zZt&f0 zEUq@+KIH8uZ+%$xNt12k=gadM)Z33&$J3B`ft5uEkU0o6a`xC6-c+6v$=cqTthg=s zGc&5i$2YeVd~8G0TomP~A3w{^hJs{Z%&Bj=#_#W5Ngjb`|5Ba4VUWHT$t;!*F;3P} zjS*e>(C$y>p9^tu6x;9yk#u`p?3AQdj2UMiq3;``ctsU^?|-@x4otOW$BK-y$F@IC z#HWs?xbi9)Rj!Y)QFP*z+59!M!=EnE^r*Em^s3Bm-&ZM1PlQ-7wB7_(zHcbL>>WwH z?N&ETo#=Zks0Troeumc4%>RsIi|X<1A=L{=7X$e?J2s%pBI{Tx=cdT`F0lUUK*KHB z6@ks*kdyUT7|M8BgyqvIYE8-xw8Z=>@jcTI=RZ9EDoL5K5p5XA78Sx4WpL_rCQZMJ zCjHUw4z+`tKGp%IbtLl|Tgb|F!lUW@bLuqClVx@8-)^tY{UM+E#LxSwiE(5$%@PS! zcP_UTdcsC=HhY(>vd266S9+D6r6Kv1N@D2?oC&zh851P;2XtT76W=Enwv{dLj-EA8 zAn+@_{w{S_{c?1iqV>V(*83oZk3W$sInk}_iz+uO6n3$wuNurhUG02Til@B+zwF_p_C}VE zxaNG(#(nk6Nn5toc5@4aKP1dIWuL!1^|pv6%e8E}xF$%X5E!9!lTv7dU zd6a169ruFCC)1!x6luk_ALlGTez&CX-4OlxUW3=){8EKlF1K;l(Nv0`0Za8rw0D~m zQo8MPYfZCz^{a#cl5)5`t%|Qiuun=fk?s>$R=px zT9&rJ)~avZUf=6bykr!}5P$=bKDt^398(-;_Grtso zolM1)l?A|}dJW5I6%`dtDyL#|>h5Gp#t2w3|GtP*G5n0gNx3SAXaVpbiV0`ZIJo%TYH!@TBu?wt0f;JanNo&d+}9^dD&%t$ za0?^Ozs6SNkJ@bpTJ}QlUcMhLcM~tPgS!DgrNZC8HQ@yWtgd$;>o5lP+~YvVItB;E zK(ohzJA(StGczmQ0?r)cCg5l3g95CsubWy~PXEZa7|9Oanr~`=H-XdUNP$8Muf^6_ zelMglAD9*99~{`N?(dfZfe^S8+g={n8bj(9CQ9{7eQpjV-!*wSkAGLDj9*^whdRox zoCEo4{oEd-OLnIpzMh*fcn~fjVLm@UznXzTdVyMg^x?HNXA+Ts$BzPy(o_oo(M6=4 z|3oIIO&Z7r&v=)wk~0P}GnG0?{r1J**UxwUh=58}3@iiftumENtP%wUg+<#JlmcKT z3jb@y$xO9;6?kDmKC1xm!RlkN%{ObzN2x>A30u0ET5d8y-_8R(^&9{gnkE~8w+_k} zo0gWQJpbE@lLOe{jHe4f90x+I@furwc(14&6f8_+H_s&VxmbEhzCa_Nm<>A9)<{mM z1<1gK1O+jl#WCs6@AB;R{tWXatdeiQ2cJ&^cMt7Kh zg7}h~I|1;(q7o7SH>VTgsPN;5QTH?Of{+ZXFaW$VmahtuaN+RuQ-!fK0;pj3^?u7B zAPEBY+8Q8tJwoEiGw?Z|bG-z(g>Lgcpr$=RL80L1pZ)HxS!+iTipNl2oK_7dq%Ci< z0sS9HdYbY;Kwk&iueRMwFhpNcP$2qAlDU5Y{&o(4PzQ==JoXF_4ue2a>%nsZf?uUA zcc`r(wf0>pqm3a?3t&yU^MU^Un1qDVkz@j4G@YOdnAq5&fDc*gby*Q*3OaZX81w+5 zYhAJ{GwejABrP1bFw5F5!4@vjYf1!j*k1-j0M7xmGdRLa!0*QFd%v5^ybrR3CtKs8 za}BP<>qw6tm066_gW)47BJ$wFhY!iT&LQAUAI>@s*3H`Dh593tx%mO{7_0psF$BCf znr{Q2k=j5lD_!^X;?K?PUpnq=EVKx1PnIGltNtm~t_sOZ^O+9f0E1702i13MDWBAi$>$JO*ts0L+B_08O*0 z^4NF{jMO|ZJp;wv(P20F!=YwxPXvI6nT%a%7OO|c#F&FYi+Q~OQndhxk_TQRWtz#I z23@S#-z!1SBlFy0sbS#@Pwo|VHA@UA*#?Sj`_*1tkg)t_ebdtq#4V0;HDInQ0x4$M za7E$Fob2q{VwyosXD7BoR$_Luue*0;Wz1l@(RdN_V0X4I2D}7FgAhM|G9VRtd3OP^ zNxn{l1O=cBoH(P(sL85sJ8nI^=zQrd(sATa#8}(uW7cI*@EqLPF>& z5oF?Or6&eV5UbyM_IaXo$p#~U*wC(<)OCe7Vt=k79<=G|r6+*zg28u~&!2xaS4(mGM&-s5E)s674#~*b9N1rwr>qGb8U(wu01!mkKtvr`8Q&zCkFJ=$G z#017!?D{3L*x!$XKebkH&XN6}Cy0}7vQhpOG$auI6+-+!&;I9QJG2iy=Ij1w__V6I zVc%mVcU<^}hlgVs-k;UO{;te(P$i1 zUVyJ6^G<`+Q0Yg5Lck2yT~HFVfw-$Myd@ep8NL4wWNLFT|L;`X;8{AA6;WupA72RBNg6sP2B%Npl#mKx}4abFNq+ zPhVZ{>F$oZwXUy-JU5f_`X072a&>;W`yDxk%iV}IXZ1b0{6pH5tw)s1<HVQiOpD9pey&NBX1STBq#%d`T!GPzy~jIzZmrC(%mdb-d3=PASN-wz7n>+VbzuXr zUt`}@;=0LvlLx82DITEt%hW*U+?*#0yRJ7vhpp0DQiO8ves+rDR|+VKF2`I8vdjn0 zllP_zJ+8z1-K^)8)r`1!TfdK8E+q0uaa3_s?8{5P)aYMJik62F zTXR~d<@ZWvbrW=De`ygy5YY1_^~>XPb>TI@z(Bxb)co4{%Zu5n_QMzTV$Gjt)-z(p z8B&-^)P9_|Gt66yw7L1_jJ}kSVI6-q?+EuVx|UCdJcdNKe=Z!~!z%9Ey~$bS<+H@< zuwFVa23KzC)Ed&ziA&OT#iqdo6=!A2*2aXLLY&Ej{j=bG0d5$Dh#l3cqwI5riMY&`Z4Astj$W*`1 z67Q!aTH|SuJ|>CCOfvto8~loQ$IRuma5I_snI{NgzJaN2eKWajL;%b_4VIHE^Y@jG z$cTuD{HGVoI}0^wqTP`nSfOQ8R@@}dyu7{5)%I12&P*fv^V+UR+=v5eGWAL`xY5FZY1Tfuu{$oxwS@tmfNE#rW@|XZmaW2uUcx` zV(-2I|1V^@WL$q;oSFp5O$_Swo#>xo; zye%ZBhF9wP%OnGrFI9SR~U$Lm*>IQ(pECTHkihk;ik&eVysA zzh!+{>mD!T;~};-8uWMb5x^7oYP2Lap9{Gw+MasuyWeIm|GF0{VAPV}naE)TyK(Qj zwkP-Q2(!(rJYCp*^o>5Q@Fdmho;!|S!*FD!2ce^@W8_BYo?;5Z7GXa09W&HN=M97! zdzZ}jo51y1Lq3Nm^y9-TEbYSJP^f!`rRmiPxtv%>PLcn9LFZV$cl^rv)0NyoxdzPL zjRWC8l1TT`E>AJCaHod-VGOd>9!$mglLx2jTx?v?hL(_6SToDJP4@0BLtYjA^g0%f z(E6@FOF2e|V=SH<2?8Xv=+nM>jG@8OifCD-nw{y!-y$osh)CbeL)P-plH9G&O=0O+ zvtOpd1MxJcLtXrjN32YR?SZ?QWB@xpTMXXHdjE&ngh;7g18#(QQo-bSeQ&CL#fQ(3 zs|wz^)_uG>+3zO4y_y?4JAPjU$(5blySbd4*yeQ|t&dYHR*|-Vl#KQpvoP{4EW;k} zN!ojzu4wFNHFxhe=U=JCH!?z~Q-UP#FiO9jqPXp2v#@b}y)`O08apQ=D#3H&uVMCX!lMAGZdF$p>&0vacZnTo5gogdT*=jp-Hh0Z9RSg6h&_a#oti# zblr{Z$0+)J<8Lv(pY3-D2Ja#R_|>x1%KLZx{Ywm13vmDC0&FJ>I)~7|D-7AITihBa zz`H4nZK=Ky73-jCEYce*(E2P%?h|zVe*XPRv#s*@oME{EXTur(@5@mINHc#-_C_)q zKa(#LwXXAm+E{lU#+jJsx7|T`?+9cM{udDH7$^_E8fpt-sbRkQ$1k|;%<(E0O$R7? z=O>R}T(0wSi)b%b&=(A8yjLWsSzhxC^z+G?=0&6!m^aYs)S#trq3GFK?sXIRE03WK$iHG`%%` zy|u^SpXr$q0a4-hR!fY6`gq`qKI7%X78m?8tsr2srnBrE$G4w8Cwk78CUrj#oBte$ zxN$3^fuNwHkbNfUi#SxL{G_sh_xe*dA+#sOV&0BYHMZw>$4bP_(^8lYUreV!!=|Dt z*vfjLdtc>6hFu<2+B@wWB=oUR$zF|Sd2)F;?SCs|-{vqEA6unezP*g`#GD;H=m}tN z9AOr~qw7q{S?Iu9)s}_g#E4-OBSRF~RHJiiCx%{mb!MOkUKE|0GR~}@ClBt$HEgzI zwmeA*?#NkCw()%BGst~Ss7tu4cr<|+>((9+!WSV^uJeHS>k6|$y%t@-(Lz)J?#Ovg&)x znE9iKTjwy8b9pWVCN9vZ2gZNSK^PgwRkG0qYmlGNfL`?CbZT z*E*R_ex!5HTq*;;6=gV?9YHvLyfG=>zUGa?8kQ0GNDqVLp3gJUE#tO!W}C^BdM6$P z;(r+Kw~5@HuR>tNr3e zua@7vfhp>qGjq(78)YAh?bo7@@@(%`wqu|p4qJn}evejil5}-_Sg2tKEuXmUR}-af z^}B`|qR}ySpZhIWyi?uSoC%Zh@9*Te<855zsIf;+%ukq4?RZ=!|L8JHo(wYRDb7eu z+hOEsTw$6VEmm}jeA4H*60pPaJKrZ(6cfQYe?unwLm()do9$48;mz*F6NbWAT*OhQ zr?oLhclfo>3s;p`vg)QWbIKdnP5Ar2Mq7!{G4sj2>UcfvEr|^W?4$n?lGn#$#fA?M@N^5^ZjSebdqNtHI1Kx zNi7iUD5!(!DY=ZiuU!+>8GCZM5^=Vlrj_UC%b3v$-Jca6`uuL#SILrjM`kq|kiXy) zlXs#$?<<5HNy<`PdVTBa;o=HY#U`TfBR6&zQ3leUk?(*t=rCb%1ImnJjViir+r7IxZXg$K0jk3pE3adJ5k~@|%x4^%e1_be%#e0U-%Ioqw8rnJ+DH zQ&V@HcMAw#bSSQeb}vg;Lx)L;&Tz0Hp|FZMLAiyU$OfKMRhOOE;p7BPj=@5 z^(Ts!3iDOXuMh6+4_XmZy^5Nm=DS+%US;d>3=3el+*GG(=hxd*hc<9>vl z`|qf_Yi@srs)|VcRPTKo#Ieq2m+b6$s6#WaBkwwBl4et5+{MORZBsJuM6;AvJ#a0O zG1*l62l@fdWj>i(eAp}}rNwD$eOmV%tK!MovUjV9m1`LtLZ`OolWHH_nZl&zNLMfDZ&r0M()GM zs0(V{wz$k!IfH~|b2-?WQ`Fu#x1?rFfzc6vZm(?LNP2R{5h)v@n3<+8#KL9>2@&(c z(C7Qqp46PRjbgDu%gI%sMkC`Rap}k38Nwd<_?I4hvki%l+XcNVTQ$K{PmoB4mWZg)Di$ z55)1mH75IM$%fC#L}glL(}o-8L=}mK?6I>Vn4_gy=A%mz=Amq#y~TMiB-m9ibl0>g zK}`cTJP7@Xe3Jwp*)2MxIIW!?Zyw>k{c(+}xMo+-&Hu?5x1c@N2$7zuLyU$iP zSK{BCBUfXd*~cC_u9%^dJaa3sf3v;7*Sqi$Fx+R=khiCDOdYUEvRkf# zTOt9c5B7N`fXKrwN@i?Z5X%5Lt1E(Z`=|zR$KT9^G{BvZOn^~oS2Y+NraU&iIO|)> zAS(d4_h4glR6XL*Mc@k312gK~z+sAb29QS`RSjj`+_+ItP>OUL&Jw$UOf(v>e}bSr z=*!)aaG+^tp(PD2bFDDxt1ulT11HgJOjXh#IIi=q3lj1=(KdOU8UljT4nTr{msv0r zk=J$4s!0tQC3sK1xlR*U}dY>+TqR) z1W@@PKrkZ!TqVE+Bj9s+1GwJT(lQ&jX&3 z_5!$!0Ms48kvYH|>nSO3?8Swf(R=$fa4LrZVCpv>^}Y@x6Cm;fA`0VR3XmCiXmERV zdU17yg@*?xZ)rAr@c>wh9K8INnTx0xs8+QVw0t)|FZF?)r|m2%3AddeV9bqx=S^2T z!(f6IW3&kH90@oRK?dNz2w^;}>LohTnwklr!?gO%jfeAq0}QWpp)62_n9n!z2;H7w zb0&p1d8ULemS|Nz77`M=xxLkG&T4590$O%NAi!X&Ho3FeovFzK;)tijoc*f}PP^0N zv}iKl-t+*bBzpU|v|1CoEqoVX?Wk6y8Fg@QaKdC=&g{IU1`v7ojf_AY3nRhn9X7Z! z1RVSD29xihc@+(KDm-fh=feTejm-N**@}}G;K%Dj8KMYQ(?5E?gSME|4{sZTIF|6J z=BrQ{8XAT@wW*wftMnb$`agi4nasbbvwD{5xwiyfKi4N&kR*tj2UxPyzlbV9W@e__ z8q{=Sy6QE9_77&kZ5IhT{{hJI0M~-?{oO4vyC>v22HNt!;cOlaYrQf9L{eTSghh~5 zSOEw*IL#@DT0W8B;|&mcXMmK(BV=TJwA-V?Tg%cW2{0@`pP>SJkava2+nPCZ$b@M4 z6t?hoa@%DLsLcZXm;bN>J7CHE?s8rCkE9I%dmp8PF{rf{{yv<27s1fT=E-vj8kwq*FK2$qYB*R|ZZy^)9JT! z)23#5d)t!45!fSxoQw!KTmXR8&DKtXOyGs!XFGM$DQ9STNA~hQ&uj-LM5lIF7P9+L`8M~%!b!< zp~{~B4baYc)reYJELDNW-fAaPl2&Srjp&%iu$$W(d?BhL~Sn>!*#F(RE4C{!!bw!HlOY zg-p$FZ(d&%?PI@G5(cb{ZcOr#DRTpMY{)&rk9ya)r$?H5PbK0QQK-C!8~Iyjut|97 z>5wv)U)-Qxm7{I=l6BaxJ=jutbx4)^7V(<~S=W`bFQ#U{+_1rzE&gv83PeW>hW@G{ z_Jjii!rseZwiZ1fKD6EV^=7=TzDG?&2~&h6$C8?RFv|wD3@Al;UV?Onnf=+HcLhSn zQGM0OiE5_1U-qU_u`zMG19ZE2T=uX;_1&JtNFOJWlR@ zgG*e5!`q(Ckit2t12pjro$*8S>)&4ce-2y&9E zKD6H(tpzKYA}qK{51w91OFzfMST!kO%y{nZJ}ZI;)Szy@E}Penkh4vD;{~-_Gxc2| z1w=y^<~N1>vp)i!B*Z5~B{<1b#_ZP7@=LaSTIbVshL&FK5^5XG!Jp6SW7Wz0^}%UKgA zZ#%h&QRB~q`Yu2Q`=>N+>b1`f5KXj0~c?7 z_YYSilXBR5tmM?l63Y>f*@_P>x08YYPxXOy{hFqAECI`(7v{cU9RI*(VWeiy(12xW3NVqtdY)>;GR_H znaFNutlf~5xyCSS$xNq+#y{e8`(uo5*rRA2`50Q7=SBF>bXnGH`PvgbYY7sF*sN?m zy+3T8YHM#tK}V+oQ8_$ST<>hz`;DP5jF8O@#1V+_Q+Y51FJ1tSh%sO@AcPUHd;-h> z6P8y%_i8tVhmRi!+&I9oBp?l#Zk||PVGFoA;C2k(>dEoN5)up#ox+D$xL z-;QXjf#+?_H8>?8de-KC?*rQ0mgevCXc}|~&1gwvpV&XFozweVckq4o+1N|IfffHrCg72GuO~u|g`*k2M^u+KyIB+Y8 zi`_GXphnU_$hiU_rpcz%%h~Q-qwmzt2E<&EJnRl+V6PL^>6E=BDPm9R&sePKE#B;J z1dAdIDTaT+>1-8Jxj00@h>+OyYFSXI;l)#EUXxx$!0wCWb92y=iNIW6HJtZlZOBap zE>tF-u+7mD#ROnnYU=SJ-Fzw+YzU?d7k;cf^IdSF@jdB4T0YJ8A=N2;rxFqxHT(vG z<$Fszhk@a) zz}k=iZCk1_vUlgbOFf#O+T(j3m%Plpuef*0bcR1agN7ATf%3}Morh1(*FtpkExCdC z!--_|BA#9L;Bi?IPzgNpal%?U=zUdH`h2y@nk8{O)?6N6dCshz$Onmr6Xb+P@2}dw z=GjhYjyU&wO+FaeV@}LY^{&NSKzFB}dm$kBX@&b7>hrtClJNG5vQxz-O;;b=c3^0E zVKl_HKRVgc5T^UGT)Sp0F=4x~r>ca6(p?w6V&x{1TNhli*dL2?rUeh4)z<&iYn&YM zmRzW!S=Msm7L=r9D;Jk;>}Or*iQ6NZD4lgRBy1Rmc@FT0J!?U@ootwJA`N%ByH`*} z+h>;_l$auf>Ya)?X4u(j@cl75?Ju9m7B+KNx1LTjxi0tLS1FgflKLfapCVRuB)o~C zBB^6JwWaY|qp0e}jn3b3Cd>C%aK^~8#Yt2CvRKRVwB57YsWcr_sdHH6PjRet z9)u0yylPKs^E@}pytanz=6ZI)eojF%*;RI)$^7u<(vT?N-b!V6z@sx-Cw>U#V@U&_ zmDEqt{DPR>QO60y>aUM3b!jCa)V#w#3uOn5XH2PU(ByNQFc{1~{1QOKnsr<>h|OHO z`J`B5uXRxwLk+^|Iuqw}Yc7&NhW%0Q*k{icXEQT0J*lI+VU#8-$)9A0`iK__h2~lR zzAt4tC-Uv2&I?BI!5FAH`Ic<=mcP)4kz%4*UtUC88+Dza0a=Alla`iY4T?+K8Npxx z*}R_HZaO2BRMhc|9{d(waJZdU8oF1(_5-c{rnE2H2 zh;UdJ&OQ+C?`H@uYNh@E;WL0Nk}o(I=`)x_aCKc7@p*i1s%TPycbQK~B*U|fdYB*L zLsT{R;R|rSVZ4wZ*xl8UxGuIL0%j3CK1F=kKiJ1}t^_!Xv8TZt*kN_AdUxG|^Km6` zyXzAE=^j^<&Llc$My_a9z*04q6aR@M=>G@kh?VE+n&-%>8a zjwjRA*0O+vI8|#;iIC=XgaRl}Ibd^#0LPbD%aG-XJ1Pq~vwd04NX#jNy zlYl@czbPntD}HmjQez;3War3A5ee+j#HO?Mbeqec(k#$ZUY%djpK~ z!IoEYlpUZibo$Nwz$s$AVZWIzfC~ez)CCGfD4;zZti&kdp$Uu{wgOx6jrk@%uq6Wf zW<;eD9UdZfvsQe4ZjH`VK37KI+?yr*`7yA_>tv<^yrf)ka)kr{nhEv6K z0)mz!aNH1K7xnHboYLk8hlWV@(dR=Y1pjX6L(7wbh zr=JJ`Xb7uGuN~M1gVn1G_+ROs0UI)?ZOiRMPQW5?a)6@&_FH3gL>v~e)*nIPO%UMF zj*^nnH@|cEk#F!Ws z^2V`%+7UId{X<7F5K+R7`S2KZo&ZcQT{!}N@(3y}{V#NTV&zvf1G3W6O8~P_E7cR| zUIvzOlHkod#!aDpZcFXo0=EVs1WUI+*J#}5(wZw(2dzT=C15c5fw~L> z{ppOzc_Y@}z`}<;xOs^*P>(wDGl5H(n3w?j48e5t51h6L5Wn*NeXeERI7VIGZXg#3#GycSaclyzN) z4deoVD;*#uT?P;Q;1oT;xF$@Q2IEE%5d6OD)WI>G8~`tMm4jfLoSYn>w6zYasBpTX zHX$Ebp7RTZZMU7>-4v69AUjV(OcXd^a`T*s$RZ&c4Bae84++ra%ukF+_}$7rc*Uv| zX;-02?Y~Mc&pHEv>GyYqcndhoW&d&kKyzgQxQrhtHG!F1{Yl-!_3xE%et8+dWsUT^ zep32R1*+7ROQk`HgMOI6iDDai$<7N+o?NaA-Zg#IfO)9`-r@^a(z#J?hm|Mbv>UrA zfHJ}{r#}@8Or$}!)q8E>qKQy=L5W2mln)6c9{;L}LaZPld=VOjXcH2J`7Z=NOTvbY zi%Ye&60@^r)r0aE2@h%b_H0bI)*h^YkCS7>paT$qeTwJn7*YDNVUs}DgBv6U|!`$Qen@~4fAyxc0>E6&76bAh3`1RK%FM^`}=H!R{!^y{_>K-qT ziH;5m3j>bX;<3F{fSwo&dXBz%5WEEdT$S1Im2a++)Q1!h)84*CzzY>u>!$>!_1WOv zO*)tShxb=EIaeGdEhF>bdHM0r(>M2ULg4?f?ObOh!K$Ou0iywo_5UK=?M?iWn$f)j zJF5TsfHCXaU&XIA!KD0~A4u>oKalkwZsI@n`{&~q5`R+{h5xDF1G{i>%1|O@I-=`k zoF(ZHoWQn)2k&m>#*UQD5~($gAF~~lr3?)7hVGxGe|mR<_ehvl#x11^Z)=@q?gKXE za>N4DYrGU!W8b%5Y#NJpEOUKL(8()}H$LSaF7)VB^XV_t2>7QFd)UR7CO|*6mb8~Y z@y`{mVm06lE)h8t*X4=Oz9q^nAr^bOG{MO#`w~Y;lKaRBmhXmayNj$C)AdE0x(Gt| z9Cf-Q`|v=4h3#^+xbE4rzy@8fRNmDOZH^U;p-PQ*R)moz>+$IyuL>S)t?HUJ|9sgm z|8nX>*XT~|YO`z9Hf2n<=%BzEe-c4_XvXWSeOG27IcZoUA~kgul!t_gXgv-c*_rHi zEVVco@i+P7NFNK+Yw2MPTC$DG#1B)KNQt=!{ymTSiz1tCOM)D+pAgO7&mn;FCnY@UlFV zF@d_b(51yBJRrZzKDLnEaEeTTT4uD}i@!hO#vAxjf|Fa}qv^#z%#OZKpHuEHo;5|H z$++8g=!_)S1Ve;fzfDsii^>Srn!wVQoA`t%jm2G}h5Blv{9mdF@2K#7%+FqJS8tSlHMUlVYHOWRVhg*u&5D!7A zvaAI<>THM9K!26Al=^qC*gU#tb{ht;_N?x$QQzlDSc7ciWDFD2-LD`+E4#$RM>gsM zgT)b?m1KDDUnQ_RRkjrx62kYx{(HKpF|G(#aia+LZ{1{>w zMxOn1^0eQr_R=JcJigy2OHNLLjgo^+3tB~YpLkQoaei4dS8#tc9ASImu&<;2W|toS zEiKmf6*oEkc+pDnOvHjOgr$5BhS~j9p2gm$BzS{w(N*74k?7sgI?3UoN6LKM&_+dA z+a-@Fclv7H;UK9KjZY=v-ItTmRBCQGqB(Jj=46t&+~{LyTw!GWWzJe&j%xpAwZF%C zPRRWhhr)j+oR!58Qek>Le8a_I#q;gj$S7u6+$qBhnxNWZc%{#=Zi$ooT)*+t&G3*N ztx`_Ghob{2E*fg|N_}RJU_K=oAPwW+fAe{d(mS#wo*tV9i|^)?KkXd!xqd+GPrI8^ z0QvI?aR|dsBg3@wct@>~ad?q8L|sy`GRdlp)9HQI)KeRy9O z#6yM2J|w@WRsJS1pi(HzmYLd8n*FUSce;~_9JMK-!Lv7!loUjcZMf|0PYMDkuW%r9 zA3G;QY)(7u&oV0sEjpLEE$;DE3+*#qS+al4%}qQgp(Iio@2|}&p*oFR;w@E+Z9CN- zkhXu;$#$($0)-Vc2$UOB<|Po)U`$LToNC;K$<5Tml0>O!>Jk#dUPXA5_qB~*T*fU% zKC7f^L-Ch1#97ngP%>~bWC14_aPuuQGAB2 zuw(DHR68gdWxq=dSd+>uiL|%UR)?HO^5V0aoZkLu5yly()iaZq)tHTqwEm3d13HF{ zM1*zIOHoS4Q5uQKC9}->wS}s$5NfwvtvlPQ`@(fON*^g$%HE&rCTdKshZ@+6LrDG% z;uarlEi%r%X;De-Tvo($CR$&c-f%$W)190!^nZ-F<|Yy#eKcW4ikGapLO_k_eE4on z!6_#rgV{6QDL2E3-9%Ad1KFuRc1L&##wa0U|zUZ+tI z`146@=jo16DM8b072$RH6_NLJ?<*)5JM65{5XUnYn(>t+P8&B%CqyE~mykUtBTIA> zwM(_7r{-0CJVx$o%mfaM6F*eDQ!DM~uG;tmJSy*OTb|O->StS`hXh3R=#y}n8yf38 zsWHLL4x0?We1lOLj1xH4=2uC*Bd0RYYBV)A-{{yzIz`mZz%r>&xhX_PQa@Sw+d`-` z-oKk#Z(*p+RzF6h=7m<3WVuCT%8)@glRlQUZDWiE?a5PW(e6!$Hy!%k_`5?lvU{fyJoj&Q=~bSvZK zM(?{^Y27(1Ye6LE*(nzZbw_(#=}|kMt2cKiJ955YoVLKnPe~~7@E*n4!fi3xaFsE| zdwult;Gt}Yq)^rIAsb{%rlpQJ{#-RYu-AYqnPXYIz+IV^;o4DnNk>+tE`b!gm&fb9 zm0U@Xe6`+roy^A;4a2A#U4Mx2%^d$Ds%=z7)t|^Qj{=R4*g18Zd>9;vddA$|W0kX3 z+$PQ{%NQw>W7K;&TZ`r+hAxyXrsQMw750U`8o2tllZ#s2Jg@Nu<~NnCJdLqens4vB z_5IheMtZkg!?3BrkO(pl@$R zWAA^S@eG0cp7WY@&FehQ%1KGfq-p@%Xux=2VHiB!A{z6 zK?@@33-jO7Pbvhor1(q>6bCnod?O^1^YX*9m#A)BKqP{>`iV`#Tu}naM|W;C*MW{p zt|FepD+V5{;q*smnb@n#VGOc9tM^4_G~~!1Dx?4U^&!1$cs=&c!VfoGQtG&m$L*db zcLLIke)9J8owcMev-~u%O{VQ^r_52%Pe);2br7l`c*D#3BmP>|Zu~OuB?r4*g=&WN z>7BdM7x#~RTH?IuOv0Xr;fIT?G5Se$*}7>i$7047=VteoZXHg$yK1?xP%OEx+-K$XV$!&l!5ZY;{8AO|lvx9pQaW^yGPBt5D_aIn%VEP{LHj zHNMx;mp;nHp} zYb;(*%=wbDrJ(p+9g|k)JI9y7H-E!p0@4f(zT&+u(kW=V#ifU zy1Jr!(D1@pK3&*5^SS<-`kek3aUs8jv-Q-bX$gv#@{f&i({62vQAZsKe$Co_;25VTN_DDKJGD;(HWM|A!P3c+my!?ZVmlt(K1tz;}cjq7H z$IG*WW5L(%1cYVwk%CLt)3 ze%uzZdYE8SoacB?9rMEM>qp&cDI$ze?gH{RTH!b(jvEF0v+wZQmMr8tK3{GvQxVXW zunmiN)$n#ZP$Y+c{NV8LRGs-jHxU~zi}^o*{`}@<|{iqC0G)mW>k}Z zWCO;2GtYwu8!1?_J#SKHmE zWkQPM4Q-REF_P2jvFC(jxmX|BdDj&MNzir5OKjuVs5Uv@Nm$uxI&7wIRUf^v;~O}O z2*$V)zx-~+OPZit#_F&xjCOgbsz5AKM)9*{;^VxDD#5Sm8Wf2kT%Dk_8S^hFB}Lb5 z<{MVuH}oC4k4++XL)s$O^`%`fUb;wC*mq%lJlQvGyo&4&1X8ad?v_Z7={>(DR;F)x z^e!ju?-n_gx4Nbc7}pqNnxq9$(;9RSJa^pp#WFvNbEu{$FA-hjJL#AnadNm~(5yDL zW|$z z@VJjF^XMPXCS`kdQV$b*cCp#Z<7+c+SR+^_e>9;u<)Edc!fgt(b z@fZ16AD+RgRQzq6)K5+63(QpiSXujjxiAH{-v8UWT;j~9^CL5AsmOmVWO>pA0Tw&I zuKvpo|GL><3f8wlF5OxtWD3ifz~&lmtK__u&tT92xD5(2gI82fo*?b?ijg6j@|m>8 zc0qa3OTk3h9?rxtpq8gz7Dy`S_zQ49FJ=?qBP-f?Am~pcBI?u;7Y{ra_TWA}J?NK} zS2|2=t8~3|40J#U`JP%(B)Fcf)@hEj00=vG39a&^5*38&Ypo$Pr{EZboK*GF({!1* zHZV1ql6E_HSJKL?7xcZ1fXc-l{EfZ?*=-=j%32a^AM|NL z4COe*HHOf|XAcjL&h=j)J&jp~9fIBQG_O7V27kovbYuz}lY8||?CS?sf*jXfFe|4) z0R=CeW$n?D}A~7g)0EuCMr!V0$Sb_SrROy1t9qJVyN)>SWP}-ML0c zH1DR6&L0j54ZZyt%-D8Y6NymCny9q7{~3%FpDs=;{G5@1GVLxcXf?l*m;`foBP?QD z*w`SdC^;~YfPx6_*mdUQGKmI}c0vi~Ez|^rSAWEGw|T?YA8$=MQLI&EWm&KEd;wIG z@H6nYmPZ?-cbO4IC18W^tEi~NG(!f>Nw?e8U+~`|5{h7rlwE}+2T)WYi%qE}Tk_3s zTpuUOy{@*5Y%F1vHUKh&Vd_cZK-647(Q3Z$w2=0UOwbX>&CSghkLm1H*)}e{;>=v5 zAA(K>A~pmfg&>ir95_BCD7Vc6#oE35EHW5SP=s3Gy}9`mf2H|!Er?TYd(?lfs;WxM z%{`*o+=o(|m=6{eXHn#-6DS(vLAQOQr^rASP6ZlGr%kTf>#K9ItaBl}V288S^wCI zEeLn_4hp)f4YFOJvw>Ppr%^6~%8emU86qo-S|9LtNX&f$1sW!EAkq;Z<$?U*=3{1N zbUQmcD7Z<0y@jzYj!g#{Auk7Gsd7REgk^;@zsrhhhS*A|0e*{!z(;bsJj926?O&@Z zD&jzbvdCjr*6;8Ce(Qz!t4%w-_2Z?rHA&#TK`bx}=fP8cepr!DT4Y%PH^gfWw$ERE zS|@BZs%)iAZlNLHLIPU^4SWEEg+Xu?vR|PeE`uoG-b{`OK-=_4NvUy?1EvG}qflvW z1liJt`(xD?d+jTSju3aXpKmD4KD-M>&%Qc0Hz+XhTCY3=O?+{Nu+i`busNuWK^YRR z%weexuv*-pRm#HYjOLn;vzsb79p#HVJsM1Xji1|<-nnQv@{1fQ!;4T3#A}-9?v}0U zrKYByTU-E5x_*@iXiYCs0Mw~W9(+znAVXv|!>Ivi`EwIF1jYO56YJL27VHaxBvw%P zfrkk;jbB5oo$-R|=dobL=}hBrI~p}WK}G!lkysZ1;#cqdxj$Bb_(;L@V$B#h24Jgy zB`S&_wL0I~t_`#%6*%^_5;T#3z67pY9pJv4p|t_(8(-lv!X_cf$XfD(G<1 z78v$YfIk%NIcy$6WHMTybJy0^7R0Nu2=xi%Wl4v0AlLWzhk!>#hdl~`-6N6@@Q9{M zOz_ds(SdglgVBTHS1a6-ptXb?2^6?gP>%zle|J&`AZfWgp{I5~PLIQ*quZePUotHY zw0b-x`>8}zVj!p4_1QX$es{dtpe?7#kd(=-14lu8W{nwu-&4Sx3wJ?3(9am_fsy1P zDe1>vgr?1t_Hx!A>qL=5^94Mzmqtkm8YQ++5!)V zViPv)-$8^5~5>)nBTU80uIBbS{y06?mY-a8=bmqR^WJOjF5%^ z0l;gwr4hav#ALfY)d|`lM9mjQaCoed(+qAfrz^=poPSSW5kx_$84+HAWTwHG%dUt1 zfG|K5z5yZ!@Hmnoo-CvDK1hK*!=W8yllajvoJB@LmC)relJoG(>Tgd(7TMx0Y+~GNi1b2`-J!rJry!i4pvB|A1B+OWQ+uD8QIDbW(AgulE6bi=0FqMaPjPCFsH~D8_MTtRQ!PoeiS4N zLIcqpn2i|0Y69IdZx3{@nKs7DWY6*O@td8tYnrEOoZtr3W~GXF0Kz4uRxXf^l7>ht zM3kZ7b^!Ahk@FkRj1ZRT?q}y{pWzn`A1uOyl9d1rZq_UpfS5vBpOOg@n5AUE^g{q* z<~M0l|JYN*g~`cD#0hQKn{)^01qM(_^#tBQMmDx_fN_B?o@kzF=aA2zk>Ebx1QQm- z9SM3V5EO-)B2_MCG@H&hebWAKSRB_=g%ng&i#`?1T5gwIjy3V%--6SUNxc*tK-2o; zvokZ#0qqA*RRlqtoJ73N zC#*m&G`6?<0mlM)k9jsf*tKGycfb;?LvGTZJXR##f$%K|mIsg;L^}#opxy>f%IxBz z9+%l;9fSAq{N)aUnTsz_qYuHjl&@J++)c`9_z)h({oi!qf{^)&ikqQXQ;8?;ACf3sC~NAIo{shzdJiOzbwB*+^n*^Vo1b-NK4jU2pW@mZ2?nKHsse$fC~L^ zDPJX8CCStUd*EJRyu?ZaT{rSLcX)Vsa^JHk=R`JE(|iUH1~E(7bb%)x!mT~H{opZl zU4oUECat!)u@Ms=-{3v6SqTX|vHrHUqR`OL_&LLK6#88dsf>PxFX3eYW>nnyf1@}I z337X6*}W)RV`dvxTQw053sdTXyQu(=u9?`HYZ_u&;G>%kp^C!kpK zlcT1g(cvT_Tj7ZJJi46rW}3Y3A56#S{#t~ky7C%kl4xYlh~b-I9-bsZEOLC&+}#}n z%nc?Ej;@uVlC@hQ^b!7q%M3ApL|ps2Ef0GywFbII{21ML>=9el_(~yhW+z3UghGG- zHWxPE`{A{MuAbKa?fvM*!~cN;_&4Y99~{7c^SbrIUPH90vr{_MK^z*h_M8L%56s}u zKbaN`?LuG|(@=D}ha=Lu&-<^Q7oZ{*r0p8DSu<4QtRd_M;G3TH!z68Hh%SZL&F|6C zzL2ASE8@S{hPJG`hyF!i;OOcW=Kn*8_&=~2|K8@?Z2!+#hp#u!H_+jDfk5a>z4pUY zOP`pSabYuB;3fZ!VQkrSTlTjAf-}Fm>QhrwlMyKfTsNI+;liyrhX49&Wk$N7<+|}| z!)pd6F)#~W9?kp*5%W(T#A(v3D;SRK!vZoaEv;#Rg#WUuQ`Ydux*J=wz3YeniF3#@ z{X;Re(?IdBEQzv@qb6Y3+6xRdoPY`YHvJ)>V_zC^p;HGE@5!ySL5;5_OV%J;n6?y_QMTa)6ht^u9x^!eNJ712TJvsPsRB8F#0cmX@yLrvVgsBgS z+GUTe=KKJ2l8ju~D@gzEl2-BY`^0*M>HU^Zmpga)@+D*F{V!~EOC6RDF4Svd9o_hBw7b|WGE|bAxmyb7C+1z(Bo*s|$A~SfXo{arr z+^??Pu=!^8NdLSkF^eKe?y>i?T3;&;de#q0sFQBi%WabrF{~nASX_4#8=trZevEnE z)`fLvYa5%Aa_8t**;7)Slp3uP+km69^9rln0I5zjqzjv!@8R4u!nF?5ovAPou#oXe zgM2zFyKC^u#o#P3*h#F<{`tH{f}8f$4NVoz<)N?Y+(E;}Dt>e-Y2H0}JVI1j*C?wR zTKh!HJb`KLLChN7#ZDQ8$lZtYhvwDdlbc_nqiID&X$kxZuDI(=CnbZJ-}(6Y-TJ9{ zc{VbQ@t`~MC~A;*&_wa>Oi0kPzP@;qapcc2JRezg#yE>+GZna|X)aM-EAT9m3HED3$sI$*h{Com&)Y=S!54s z@}BNbcp{zj<0~c;5R$vG5ulk)R;xN`)hU~89$pFGz0tm7A6=2DP(qKi+!cRE`|#{g zEj~Twu5muhP3dJZ?%j!(lxASNT(VxuvO*!oiZq+TUYFeoABS22->+Q zO~P!Y95h6tGu7*`o7(54W=I2hI^2AGae<)=x|=p}l{QEjs=1ZQGX1}ohz0h_ycq@L z590bGD%LcJj?5zU;zLhx;6Tz08eZ!9n}238|LE|>vx91z`9br19l4_OG6jnpVUu}1 z3W4;mO?v^G=5Oq-jU)>G^a*)2f5$uuDBd#5ON-{2lStQvIpW}`f5EL4T=9-W_j4n4 z3`2Gu6;bVLuf&~iiCC}cy;Fuox&Qc8T7xA`*K_AWdE*VrnVo`U>s81G98jb*Q!7E% zKCZ1na(?=r0!fHbqs50K{l>{6{(cB&-R9_n-zK-TsHo^rJ{N$>G6|;8djX=9@LZud z5bD)#_3R_AaM+^uVRLxYO;&9GaGI#Lv3HQoZ%JcT+KWd*qB#Dpa=I|d+Hsav23Pl{ zMtDZKB83~PT|9mGr-7{yE4QlbFx(j3Bxe+{qXgOSyzCWEbd`mhuID02T?^`K8IE+T zzta1nI5jIam9_HGC+pIi^vaO=_j=VojH)}#eJteaLcN9B-<|)Ve^XewdCs+H>oAmY zt%GLs=E}Ze>rMG6)U|nsT?>nWY^4~M+S6!!KI@)7k3iDoWf$T85+}PWo=Xn6-ac7d zca&C7R@t(h48DNWyTYh7E@Q*yv%{^(7OBA zskn~3syScfsyN=R*%{XUZZ%_UgNF?xbDR6u;fak85!DKJ@}rXR;jZ0_K^>JMov}d1 zpl*pMk2sIR>00X(1xKlEd0HDgOUt(5%E;JQ4a0RO@=N<`kax3*TZzb z<`!?`#GyEeVcVk9+a6JWoao#dp@0ielbBHb{JCX3&Ya21`e2D2G{z0PC$9(UFwE zbSVjbcf#;gzFO5c$HPuRagX}v0c}bd%E`!&%1pbI9OQ$8IUTadU!==`ASYgt+v*H$ zsVhon^mWTG>Hq>lj&zxXWHYZV<<&;NbN1!z?ze>pH1{d3b~~m!BDrGBmoE)%WO9iH zM@#j>297ldqWtYo9CogHk9Qfv5ACmWt;!-5ZGLQK5YX=rI1KTG4k-GI>Ld<;S>tN( z_`A6p>xWS@=BdF{2aNrOrZPQoghle-+B9% za<1xi&!PsO(`_0WF*bGwPWG{5yJDTT5;|3)=mk&RZ&hQ=?Ng)iqbNm1cE}T2={9-Fr0;DEu=9#S2nA<={#?~N`CDnrep`1w z1WJXyyX3+ys%UL~Rjk#Xbf!br(tuRmTg@raua3=gNeEY!PJKP=!S>cd&}+7POD!#d z)5kq$Q3icZVOecfxoU41{(4SU_hpH^zK{JTRgG)NY)o8yg(~nii4!CBlElLPD%pPX zRxXCKt9DV7=U3$AY@bt}@6kWnBIk4>Cq5N!#uWKsTPZA!29T-9#_~iIMTaS*5hV(=T%IaR$B-s z5)jaOTNwK&C^fHOyQi*aZV|`DTE=38ki?XJOf)+`BIa}S{ND50^vZz&9^qI8Rxdug zss4HC5ykJMpXU}XE2tF2upCriJnD?e`bct+;cK$N*81UKUH)F>9$!K5kZkJ+@5rv+ zMg(^^f9=-O*@!Y+oAE<=AL0|OG>GnzR-rg>URr@(2TYtgMdv!Y@k7elq+w{=>2epnX4GrO&UCR7vj z^|6f`8QSlZHkTEJ!aGRQOlpa_56=~<4Cvm`yX1RS_$mLq85i#s_@mL;VLqM1l-~{i z&eJ^^Vc*I$bIqY&{&(Hp1*NhI#SZ`aD3~plh%1ul7_c^w_acI{oH=p!@ZVA35$qzC6*3p*dyX?F22 zG^Oe)E?*fwns6+ADzlmJw>2HBiDBL6bON_qUgCSSH<4X0y_@Nro`OQ>=3w~iIyMeN z46~c@ItrI-IlL}DOVj4F9>2d;k7q@D-+v+!OT(yKFC$;_R%+~0rc=Z^ZQHIk$8i~N zrNc_M1Cz+U%;YJ@Fs`tYV$6CU(yQjN5G@a{Q|@AGI=0w$pIJ|;RH4GfqTENr*pw&c ztEJICtsQBb{7h5n(Ng*$Rzi6P@4Af}(g-RD!^AT&j*Q7mqpI>)->={KI3>N*cixF= zCL$~T3)TU$EY}w3lo{2JpD#mDJCv695-P4+*}MO^-si1a&QUu1DdJ%|MR?Rb&hkh9%l245PFy)@5IMsR zvH8|OiYRgSV8v@$ruiymbJki-lX!aN*y|GGwzMfT!m88Adoe@dF=(sgSnN+DHL$`< zqf&m%lJzV{8CqF}@0;QLKs&{!_{Bnt|JxwO;qpfTuG60F*N>x1ab>!=`Nx}Ki@PmB zYDsRxruMO7n{#d~%5#^w_#J^ZqU$f;_4+wFVH0A0kx5Thj^i81dglkPjBk?lrRkOO zE_xQzMKtoXs=8W3>OsaBJ^$iGEE+oX(UhQ(kttzx3+=^CzR#;M(pN-)_wtV>ht~DRYbX2X5XZJlUid@PFbEjIf5>5Z?2nN@~i+lWd`oXTQ z!w#6wZW&qWUc6*VAP$I6Y|h>PV6A!ezIR`+D{phiq}h6PG$EtP&Pqu% z`M0eyb(%ynGCV{3OQlUPPoMDwO)j+TQ8J!%-<;~Hsb6nfzc^|%S6xcLWxG$qv+nQ# z6_q0)#whHnz8AwZyl8b^n!@#?`Kze_B=3oLvaioC^YM`~7RH+<=IwS9H=870QAe#i zC-sUKTr>SO>yX6Uc^1#b-S-m{t1Cg*xLNZnS-O4VFW#YV9Z9dAuCrwK?^+j$KAo8Udg`@kQrJ;-(ZWQ1`QurE zwVgPQ)zQ0#O%a_|*2{L2mynq8kAZ)hVxIV0Lsrs{TX?G_Yh@j~6J+>IG>jYHM8zHkPGDcbN5@A(=c9z2bgEyKW0v2z_Byw9zkB&P;*mwaCf@GWynGF%Yy^)U zYRrNuD>A1HYYYKpN*at14jLV*VEXb$y^Ey0%%3a~=5A%o*F+)b4?e$rM)v~m#Wh_oY}_r7OSQ3y>#=|rLSM1Et?%kW_o)A#&dy(Kg7KT?4K+5FqffZ3`jmgSm*-4M#4NgcEXq|s z{broqboWs#3Ykoa{qNgw`gM40#%RG-o7xjX7BLiRF%Vj=f z_Kx5#UP$2xsI4ouX2dJ>yGq1L6yo%Nw+qXfqP=6QWs7G|Jlk$8`UG-! ze@v+RL=L}~p~2;1;tlm53Ca*3>6o%veC-;;nKc_#jMr#nY(w;$n|96pzZ}#@ADqAN zUl`(We!DN6ZH+IBPjqOm8y4@(LMZ$rwb#zlqVHSF9daSMUSm%4yF`{G-%ktFZ)PHk zOUtvSt+1wzycaLk*$HaQ*&bI7-BnDzt#}-Mdc?Di+IQz9uj^jh)0p7_!wNSXl+2}5 z+@mAgk@0bL#)YMK10lXQ7K^P#zPwxB?mP5)jD%(IR%YYD`Ok{dM5SQ1s{1w~Zu8Rn zU9-a99#B3x!0ImH3VNYc;T#pM)X|hYdA}RpU-<2G1DvDcLEfVe3D^Z0?XOeGdZY=B5e~<5*rR7g*JMnuw;`s>w zZZ<{2A#uBll1FxO;kf{bt2+JU01P=jQa$M*{DrLbJcxCeWGx z?j?mO(d%DwsluLyH9dit6Vqvn*7>rAzmPCVmKg6S7^t)j(&N2DQPz@n@ z3_GYT4oeAtc5!^aS$C^nIoA8#sVRBV`*^6x zB9G92PY*{Sr%Dmd(T4AaZu3#SZB$nb2^=088M}H_lkm6DUsx+vIM?av^tC1%``1`l z|0*h7BGOWLeL@mQEq)BAucJ*3qKyTxG2_=)v?fbJ(3;G3(GqVxhx$)5= z5nb&WlfS34&3fEUdCTq?g$^~RBPQ3IA!fYluB8*s-BVNXXNPMK@rXLcg|&*>%%!*# zYCitd_$=^g_V=A^&c-gjjN12e$m`Bu9dF?zK9)B-Ae=JS`8_y1NKv~U0MD_+ zEBV(8y}vJ8H}cp0PirZ>A2_sU+)9esk~^#fiHV6%Y$ABku3t6>R`Z%hddJ>jh;Z#r zEpnS*TFOf0a?XkuiC-~nH*DJ+w<}~cKggJc2kV;}V_eK*@{^?8!#7f!)8^)3w)Q?Y zvcPkS;g zzUci*OF&AL-d>ov86q{Pv4jw+^OA)mP$aT!_br)3mneah_U>#_W=22 zC)1s!?a&wRZeU#QaG;q!?-*NZ#hCt zf7jEwJP4qutr#CJGp#A4h?n~*XMLnd;u7`jb!AU(*K~EkSQ!7VtpoFN4L;?{#1r&o zJTG~T`^p0<6zuM}Ah8_O#30taJXziSc_2}z)0E0`dnhye;DRiCYf|&WVBzVikX+oN zY_WsYekJu_mLfGH=W^JO#s&cb@^eztyT>1HteTjgDDUBtf4wPOQM;bWd2n0kKxt&h z7%k42TBmJR6-~>Ia{8dIGu}sKWq0qfhGx0?j1-#qE74!|6|eDq{QY%DzLTqsg|$bN z3=ik0njg+AEHs!q*}egJ0p=Da=12}36cMD(8XPy{Zn$VIjpsj077wPNP-Kp>bU}U8 zY}TQU(Jf<8q9it78<4xF*QK*K`}OA6I6r*cDZX1-KEZWv9VRqx z&*i0P`LK~g+_OIWO?k%&j*PB`?Z}m&oD0dQ zLi_((Ncvad>EG?p|1K-;EBAhdmp9LZ;s)p%_LMg1VaX`ld|l4>?pT_LC00a)8o zfy)qtynq(rF5pV7{SMI3{sD-zuUB69I5-W!PcZe>UY(ghnVdYgPv<0VqSj?x|NN#l zD*=MAB;?RXqYjbakKfgjll!hT2D}`qjf+hGEa(8#&LG@`aSzzM2cN+uI%3+rKi0H( zc;Vg!zr{&HNkM^B4qRq@8(2K_foa1tf``|_?=QD_<73j60Us#rwA-0^0S3t?kcKq2 z=r)5Bpm;bhFAoq1^v?in7P*|;ZjJyK`UUW+fJZTQX#xMtPWxKOb_%qVRR;&uh`p|2 z0Z^M=UvG!KL9ls%ShgQl0Q^LX5P~2*;x?r%esB#RPQan>FER$eLlGxCtgVch)M&DN zTp&keJ^^viH`NT3>Sh3NklRynK!E;$58FiuB3L1B8;}z*V`F+i36jCXsx}5{DMW-6 zOU;2oVqUkAqrJWTP8&o12q!zBn0K`ybvil^qrb`JpjYggk1`}p2hygAs(_B0SiQTE`EM3rIIm>vhNiz1 zwW3lZ;8SxHXXob}t{3*%)d(mQa&+vSoH#&|VU)`eg$KwcIuMQkS9oaV(+CI*wxCJ=1o_PL|OAi&rd>l*+sGFAh`q+Aug z^~Ojrh)saZa%il5tknWElI2PdIfIqeLJKB*2tbELPn7{iXy&WlOX-EgErh-VLH3AC zOM?{^>oa_v1wvK=SY!)){8PIvV!(*?VJsk=X>Mx^{WnLqfR6kYj0vz5vw*-N!5~9Q z)N{~#BAC=0<@UQ208pnI4`#*k+XX_-IRX~~fpCt<5S-8Hm(NjeDP<`p0tEq=BO+J} z#3bLcvLJ*gqPqvACm0>QEO0HTJ)wI1_&J9Iq~>XW!Ugm)1s3z7W(4%Pt_;2}r)ynS;Wh?IbNaxM zm`+v>rZEF@Q)sBpjzvuDUsc5q$KmSo+$3%o@C}HRdJY$>YK3KL=XNQ0a={dWw9=oB z0cbF+EHi;XXJAe9`%r{N*k9!7#j(w2__K{0?9+|v;hPb9SO|B{e9qFep_ixhd^(6d3oEd zV1%0i%0_t(@J{NFr1R}@h_&S{NhIY$$@?x{`A^of3U?0FoL2eAx za3SXfT%~2cKdJ$mMnp&gG&eUl2jqNA=l0P?QMd6j0KNeL%c7I;HGoXIum)e?-kqz= zR2bJ?0Bua>(&ejc&6uR_u!1NFLC3+~-Me>>wm6^_fm^x(BQytrZxm0Sys{dDKcWI? zX{{O{VR-7~pd~RKZB4erS1^hsBE&=ni@y1QPUwZ?GCGJS`a>JIb44G7t!a2>t|5;B`q|0I+*MX;I!?hzaTXL5WZG+NZk`A7{QVI^QR6MbNs~X# zv|lsx-Ws2h@`3+9g}_d=kD_wEDZa8Jr;w+3J6Pd-~QN9q#|&$Nmjj{d<@HhY_*= zU_Tdbhvp+my%CS;f#S=zp0Tp%nXY*e`S{*7@p8V%PXE?pThgi)pZ3{?W}1+b^>m63 zN(!#0IDhA~Bqkk*yEJYneNBpAQ5*VQ`R{XoKt6FXRmPp>ZFt+tw<=8i)PH)IhmYpcka_TXG%^jE^dRYD>yU+god z2j^vce)fMt-s`&75qA%JQOMzN{{4aN_KnJ7MwI;-v+YH`o;(|;gYLP~O0|**mntVu zDM!qVGGmUPlB(Se&B^yNTN*Fki}1_6a-Kzb!u`w#^-}*63%zo~i5F`l&G)we`8V7c z;bx1HlY6ct;c8CWy50HL=<43ac`<09-Hmh=aW$`2*+sEwEALi45~WVtJ2S_u)))*k zXn)$S)b)iQMNU64I@O@{{v+nBr`=z^48E5!vEcXDA`I| znn;<+qs*(A0pn%c#)Fh5@?Iw70yXB5<&|9Mllgw)`+y zaT|StlQl8eOJkLB#9ktGm?fxo;!gVe)jMHM`94vYsN7XS?-ovj_jCH*322;}ojoT~ zWM-ley!nGq>WR%VrW_8A7(VT)k8llYq@B+xot5^?;5e{{rlxjrnklJV!j0y0h`cVcjhd6f$Lr}ZBIw{*UCnco#RhG zy+cTj2vH?Cqsxg+8m8Yh6?0h8d~t)Q;0DIur=*|`3_>o&#13&ub>rz&^ue)G`&@MSSNkAlTDsPT3cDtz_eJ-ZM5)P~L|(Hj z(p?Xmy`I^m*gAhd;u<#5?IT~$`bWag+NF2w3;xiP{r15L+@1xRuoNCDdP*D(>^DiT zQ0 z&MSPtcO83PO~%QFG3;W|&=M*v`e%Az-^z|Av^&Jo%yWy%!z%>HSfNQ8rUF)Kvlu0v z{)HQO4l)|m63H*Lyx(F-yo_?i;3-fs@G_ZbV#Hv`TMfy3@aSkZ^1J6;68>ysi-2Y= zKS6xDPfc7_`FQQ`RvqDB@m^8YaS?2tL9h@&*R}<8;>jz(W`PEE3HN&)~F+VjRl}Qyb z;{RAP+mL0IIlz)9thCQvr{BtZa>d)L*`gIs7UdekpgGb0r{PID0bSpg%eqyxiov0m z%dfJC*^TP&I|mtedDwj2O)kl$SvQj*z6OL7JgSsq|h>`@0xHr4-OkF z4qdE7*CBFz7-HIXAiFqudF;)T@%1bz|7k?$wE28blN|P4^Jvte}pk7#WLQi7~5I+|N!p_+0LLoLlMlP32dG zKA)z~`+AhLU1M~9`nmlU3Ei^MQE63v+Fl2daHYIP@E@`oVxz5eVV-u&k5YFq%2{b$ zG%AOZbY(<+voTT83w9+uH6IL5C8$3enk!eK#xAlLdp^dMMe#?_aGcb6^~+LI28_`J zb?63Zs))Wmx{HP+q$ZBXE`;<;5M%G;kE9sE_?CGjV7j- zu;jZ_3>KB-&*60=n0JYsLDmOQ%g|ir3Sr=WIuH~wwhk`@9^lvXn}_+XNdEBr)|L#U z(TBT2qe*TD1hrK6@K}ejztWKmJ4n;;Iq*W7;Fl_FadI8BXJf0W$BGKSBB{bi*Ay*R@LdzSk`mp~J9cKR zD6ar&PPV%nj*0`p2Z8Z@TT5$&1=*AZe>;L6D=`>9tLqNOiOvyEyd>JM=#*4@)Sfc% zCmWLAJ3$7E791QLN-O{Jbv`i8kRh`K9dN|FmY$&8+yUR~+sFiHH;Yb2EhqqGkfj0-0f>8eKaO7xAwRdd!x7PrEL5jdw4!KG`2n> zd2QT9nbXi}D-e59F!M*O_5ss7KN*Lg`$>frk7^Zr0|M}|7PFoqQ7eh0(>`7OeSSq7 z>=EbA$L`XS(fdO;P$VOEV_Q5diBF31V610`Uv{);LjzTZC@OoP(P^ktA_+70{_4$k z2(5SzFRK&P4k6%jO`j$f7M74?pBAsFt}eG)Mh7!1h->BB-$29N)y2N7-yU=Up(0+g zJI?C%(GwFBIZa2tL%uT7FYWr9ny#mMumY!?v3}&aU1pRYH}Q^9d4q&q(8c9PSFTC_ zPv1k^+Y+tF)qJkr#++l-CRe!5QS9%YHPwrB*D#Si`?0AMZ!e?O`o^{_!{(=52e)kX z`fJ*>iGy#i+zKLN&UcFjSuk;9l(9 zS97g>7@=2T%a_@J@v>h}UndIAieEe(OM0hK2j9;B3OyjMP5z*mxE8cj?R&HNuk?gB z$F3p@$y$}8T0{vYy~JW^z-RUMwVVtpdAhK$0`KM6HP6;^?K#y{4MPf%h)SZii0+v3(NC>g z+X8VMw&@OqQ-vXH)pEU^`tdTjajI_5UgVjQo-$}O8rWj(kMll^Nn&47p`@ce*%PPt zI(zzlUBWgbOTjL4bO0?QPv`jqe&u+s-FKg|4nj;>9rkn~*au#7Z6aCU--On6IwWYI zN`j>ir0T;Vb!EXb$Z7cm88Tq*bWcvM80LdlYHI1i*zsOyZx#(C&a4pv!RPbRrp#Zv zir>83Tk`wx)y^$n8Dv*mptN!lJZ0!kkS|@Qd9raqR{3XR!q4%=z$Ec48fG51mvlca zJ@73^C*{y26db;~8|X@wFT0lYEhvTskAK99(tX*hCxkqf7VAe4729)zn@=jZwMQ?c zzHerNJ6kX@^Hx=U^vQghWDC-Mr}dfEs$k03bZJI`I2V?VQB9^|TYbpNs=jtTBcg)k zqd^LQ^oEKzT$Q*K{7dU!V|?kD$Eo(IV=tOm)L zJWwqDf*L=h-@=A3%i@5$C;jAg`JY@3nT>7&;*rsI%b+jP_)-pM8e&y{#v7_Z)QmVf z8xYEcSmS#QD?k*m#!1X)eNR?aHcPR7j zN&Mht9ee?xmF~j@f@Y()K+JkhL)!LnZ=rVuIxr` z<}_H}_%V;eu><^G&j%+5TUi&g{x86Vo4jyw1Nyze>peD{uV&-ab{3>ZV>Hc?p| z7hL?t-#^=R`0wl2z(xMvlBEFB>Y`TqC{A~3GU~jF)w$O9cd+FKJ^FLY2VGfZ)hxF! zHmRN(p7ODVtLvt6>nY5Jcf7Ux9ma`eAf?=M1KOKO zXB->~jIN|gEPstp^BKq-yp9Ug?b<~>2l$VgtFhFnp}vMHCwH!>bPl>cs&fF4g66Frb1;TK7h`{!q5>-!rS-D9i0zFc2>IqB7U zJ6%==_o_HrL*3QCU`}|9P$WlQO|P>3*uPRRG74KTEG=c_l*k_vHyD%Ft8Nfsd{6y^ zaiU3NduAD>d?wmPz~82rpSb?13$JN3GO6R@d4H+mQ2S?BUs;7j`wpKS*IUnJ$p`LZ zP9@D>)RyycWc0}VbZVNK614DNW<7PrJQjIvd2$AI?Z0AD+r@vIMK0*-c9e~u5;)NQ zp_G)>x|wMu@iYgwjE$5fu)e9_)u?4(aN))6m3<97>kLBlY;lE~4N(fZG#t(Ei46`i zC)SORUTKXxOQ^j0SP@e?d7!H(=4_^0Qe0?#7(P{Xs(~FKo^2$R)pT;4M-$Ei-S(b}Rk% zwS@1K8=MIdH)pY^(dlV^eS610U9bF#{AbbnH@y>wR zmo=Nd;RZJUC@>triugpk?{UjCqE@qS@9vL4@Av6J3X&@d(_F$+rw0XJmUzlp8Qx>b z9fgk$CTaKm9-Qi%d>yqC_uS$#Wm4-a`4`cMUlSGO!<}`x##X#&lDdyw@Aoa_CkB22 z3ZXj&MRj>>$gAu3<>em6?bsTwKR`v%40}d@j|2Uqbaq)Fn&b1wCdX=8YAL>lhfj?x z{7{6f*Qiy`gdU9f?ow!2-_&e)M&l7gP=Jf6Ak9QqUq1EsWKJxvlCRM{yyxkX|M@lJ z^rAX1nZsv4GqB`Hot&b~)zd%nsKXj_lueKAl^1-VJw3*8dq!RPLj&&lcr=GOYD=5` z87=8t1nr3Ma3hV}jt|+Vb`~QxFY^;`OvYvTJ1LFhMA>C{PF!7?zYBx3&Yv+7JBf+xE!t7 zJYxL3A32xKStHGz8YM4UsCeZk;Uh&t1?3`LNrft>;!yOhu|}u;4~!v`>6k+nBt*3< zaUVud*nifPp-B5g)sH4k0|_hs>N}CUz8=$sB=3QWo+=1_ah!cAd}6;_&g>KH4%bke z`&hg?w(jSCu3gyT_#5Fsx;-zsu^Lz)WR;FK;aG1jnrB8O%qxR_L)_XkeQk@AErQx+ z%hM%7?)%{aZ%@I1NApx=OcBqA5xl-QW&eYd}gz2HJG^{Noh{Z_76;q@w)_Y+H z7I~4OYNMs`ys?$MN^SgmX$FYi4qTky#LU--wYs;PknKkwM3#K%mh2P! zCGSYVS*5sd7_g#)u8@H^&rNg=I41od^7McBbA`J~cYufAOQfpVlx$P~S5~7~U!p4g z6sYkJ1Z=FfMorS~wp&SJl9JSox;@s4+x;%i;ea2>B#e3)h<3q*(E(f@k@_4_+mn|i z8(?&h|70_qN_gtr9m;}PIXTsZPzkpqnxce*bwcH|h4sVM_1z2Wig#09r&d-dd858P z;XG3v@adiECaJ9;^1?)sa<=JxsdTrr`#Eb}3m9~`KvldFY8!Yiu0rW}|2Q4G?U`Qj zMar2uP#-knW`9VZwqh?e$OT6x;7NG(GnRQ4j}GSoqz(RA$wu>zu5`zl9QinYEl#z7h8@LWe&wIpp$4e zf}GMM_qCaikwSkyeT?`MKp;DlXb^$gxOw=io+q)c|KC^uPj{%v`Hkg)NLJ;Oc@XW8 zgC6tia?(k2Te)dz4F$C=C_awfC?oa+-x}kg9gJ;lpsGtPfZMuy5!Dk**(r4nTqadc&_%3=b6z-X5V0=^pQ5 z z+kKsT>>+fxSyFp(vby2X+QiTZB0im+ov6BCGXv>mFx>ylw#(6sKOPEVM2eLBAPKem zIxY)Mmg0FZT4p)=4l%m`^AH8l5vceAP!B0{bMriVaA2WD&=!=Dfl!&A4JI-52bCJJ5FbKuEW}UlNU{?k(E6q@pn{3?u zj>UR4gk@GXiQ^4;v|?_uN`{n`A#x-U!{i>M$MA<>Cq?E%-f?!aTDZ|U_=ZGV>h+1jwbt-z#^C{dAJ)ol=G77vP^ti9D)gD^)dB-}8)6s(l}-l`^MQ># z#Ubf7j8Q~?1k8&2plqoQ^}qh$V#cUE4rse9s6<*fgaVX>6ST&7a40A#Bdo?jy{ywp z{vh7?4DN_;<0)A6!Hrw*MDEyWrz5ocz(mM0%Cs>sI#zH z3^5;G%>%75m_|2z0ROe~OHLU5$0cNPC{fYTO*a@)8Xpwag71Bw;NcdO?9mW6AjB%~ zF*v2b?mObBq;~m3=n*tEf*>IGAatJ#=ShTFZIWX5mVX`T5jcJk097nRuL~*D!*mOW z@#p}`WoSs?KxPkwl^a-C<4cF_ZEe-vm%|Mgt>8XX)py*-JBMvu{g)nj6NI{FmX=?f zf@p)bPtSYP2!_9?D}6Pq0I{VS6H#}Aac(j)G7zhXJ-z^lOh->o78J+_5BLvyz=?eR>aa5VawD$Xc2*SrdvF3qBSLT9ya_zp z(SE^l2qx5tFnsYCt3kJ)GX@%>YLivFmiJRQbZi40&ziki-AYSm?W=X&_2WnhESboT zN9Rz{vl;$A*r;3xzzGnnkbeGr4#JXZ71a~v4JK#CYUVPSNyzCQx8;a!%hZ0hNY3IG zWuEYtYJbxE8Y&SMP^XmZl&z)-^m}?h^sfv>(OqRYXzI4KCSgZVPzS2CoLnx*DZ$`2 zDBLwPG-R`xs%1K^mlHn!eXcE)0sMeJ9=Pjftcf*~d2+tI4~TcDDfu zR85OX5{a$Z;N|ID2QyvMT-in8E-IK||1wDQ+uyA_FsTU0q#MU>^C@+T}Ds^CF4H~eW)OG-;8qa!&vIbpUuIJCRk3J?JhOUpcc z4D097(NTsgxAg?*qWz&J3y?$mGf1ILfuCABHIDw zVCg{k|NhIn){!M`(f7Xro)glJtz!?SJWNJrW;m`36uB!RD*D}vRdS&K9xJr#yo1@9 zujQXqTIxkkZ~gz0MoXdRMgEg!L#8do)jx>bDDsLn&&c|(pUW%$lXye&opqb>K8&M( zUWz!evH$Vzn>X{WO`V_XzIt_AU0ogUaJO#X4(VG`G6P^BGjmo^QIwzG6#%R}fou_; zYOsXm^X7*WmjC=x9Ne$_8QCKH{CV@{p%RQ*D|EhjaB~xtlIozgm{6IP*w+&O_t(7c z;Ndemqn|&2+EN4pT=jEV_L!lu@vZAuaow5=PTvoLW2NR(edvFFQG9v)DVG&=YyNpDqtO{58xiF7 z+4mi?HTmZ?C?fsOX665={j`am434JYc^mODciO8Z;f$hUP~_@Uald6`EsJ0)i{yA6 z!TlRcC8=xH#Ozx$-F>g0SpK;T0gdCSqNXHf%2NN_f&0yGy{h@%-d?|IU+ufc#g592 zpRrkm{*W7|z_eUyBw6M)Jl1Q%4MgXkIlpCdTuWRxhn+jb*C{z4M7&~Q? zVG3*W^-KGwpQl59a0|2e3lBa1BICe_9h=6`f@ZZ!UR}YqXKgaTti3=_qo^_RpyBPN zb^Er7KHoL$k<`MrXZ0j=gM&&p^KL(GEesGYnMrj-r$~GN}3G{_WAk z>(7B#%bRIvBncU8_z!q^W+%7MC^5p=bzLV8zrHD)J=?!6Ye-9aM3bZEO!S_DulQ#K zFN@11y*-xb@$x$pGZp82%msXT#T*JV^sVo&lJ~2e!qs#4t^}H>5~ZS61Qk_gR!OAd zTRq?_V@eLYuM!!T5-m3GpKdjERBS%JrK8HPd7(%h@0%>Q%~n*pQW(@R+oOHL-cyOC z6cyfH(n#9a%6!aKi7?4MEnp8wW1MxtD)g)T43NyVTkcQY&7N_r+v{`b=PdB92yb8 zsvG=VFw&0c8+WIh2uNJb(m&ZJm}7qSU8*lWe%vZV<2`Mbip_(!mq8n#Y(}0|hhASr zY4NMig>i*w&P9MXLaY&YCOGt7Sw!Kd3T(m`eGgh1=m-qRnGM)TYoAZIX>*`6)C%?9 zk12YeCV${{o3T+kHL`d&bfsjJ>&#HuKt|KT3ZE})$THNVgTJD~uy40#Eju4Z6|Rn7?=sQvEGoCbx;E zz}P?B<^HwriYsjG&o@qCkM)33%?N{nQcGJZ6~8(0o3?&k1b2>d-e=ji*s;UdlxU_$ z7x_SK7ka&}tB11Tk{h+_Mq2QA;3Ll6K;?QW7WexiFE66q*U}FXn%5TI$kg|WsRc#g z+3T*wb|z8*Or5c@^ueq?KQ1>neT$f>zZ!F)N^IEu0l`L6e;yh_n_U-*ae84v5)%`a zw;!fB=dIrK^|hm>aEr}kF%*l^I(u)|k`#G)PQ2I=QTWh~f-UFUan*`~?-K<^+J)KO zLl1DT+P`==ZBt1VS-xo?S>u++T<=Ku4Gr(omfN6#>O0!G@azO=CS7do{n@Yk z+``7ne=uWwt&1sH%nWZTNk~W03pGmw+bVoOW&TFoIFG~q-1((t-M7;_8`|@^-hQnT z;d?C(e6|?;WTgyrCi5bK`{kZe1RQjpkA|B{EH4IB*9$2gI}Gz0(Hm|EoNvkEU!1Pa z3{;fv=xyAVvd*_+Ikn@cc0e;e;VN>ZOw+0fZRY@0D<2gezoNt~#bDC$Vmna}UC|=U z65ZkT=WTOMs=}Bx+E<FtWNd+CL(XLnWBi&!pHiA@&8!1w;ZizyC? zV6t%*5lLJ=H5edE6Iylp$PjQ&*@}F|+RR~lDV=Ty-^Guq&vw*sY(+@J!(F=*-Rhx*>Km5kv``BeDXN?U2!bCu$-=j*Ww0R)xzxCtW3?{ zi?3BsZv1SRo1L`2IJK}6XCV+VdjsFa~^4meFI85c@&;g^e{W$Bs85)7G8a$@ro`BiYAqT}?;p zT71)XEG4wlHS)=11{LkoJ1#4^zF{Ye`WtKKL=7q#sxlJg&#!L?a&DYb3)t?ohy6XQ za_d_Qe3c$Mx)JEjQogNvcVM<0yP|9_R;rES)ucW)0l#Zv!VT8K`^-Ezq=a z4*zCq*oK;ak`rB4?hMzpEtIJbv7vTbzr!2JiHKj5-z1#;J|EDkPjibc{!MxNX&b4) z6#l1QgzZj6yUzOd+y+j9*`1`ov~~($wRJe0uu?Lku)Lge(QD{_M}^U?vz?U2Kn{y6CF6 z-r~?Q5EPisJGsR{=NRcLomy9x*Vt)ho%!SZxz&!9iqmLC_9(8P7mM!9+54EN`+2;4 zzvh;%#2Y{Gh%R|Jt;qS#sar9>e&C${fn)k+;g7LTn0IN)qP>aZnoVLtKmCn>NBL;QnIjQY7{amJeTkxU##vn+ z^;!H?7i=#lyckl+3xo})-Ic!^6Hf`eZAo1B_$D41QW*AXQfV=1klWRF2W_d8{8frV zgRxd>1@^??ShF`bpGOT>QJ-rmmQ|V#nseDtPZ`BmMs95Zw_VEX66W;rWQX(9ov&89 zNmHLz$H)ts7^Y)zbvIFcy4b(Iy;i3^mdq1<+U0O{v1g*2;+otrer|QVgyBR4*CU(BwMn|w+_CE|4BSPjt106dULU&Gt5NX#q^2r7s>iNz>u{;% zWe;z%v&FQxE#axEGbzHRwu|Ou)l2P+1*Hb$0GG;ew@-cd-IgpP(2<}poi~PKmB|j zeO~n6w4z#b>kB`F6P>U zltZ+FcDp~iTzC*;TGuq0P`ZIMr&ll5xq(6sua+eRXgC65+ zV{~(5%RR&)jl|Gj(E}`J6oz^-`!}uFJ@#t^#K8n()r`e4X&p5zs1ut==j`S^T_Ha79x zqa_bo=V!lRXu7J3w`x*oBwj(_MWGPg;qi8yj{IGb4NvDk*fk;jnaz`(1DVlfSXS9M zEHvdIr4Dx%cOBT0EZA~Viq$647$qD=NrNmR+IK1k?Cc`R#tGgMX;6K^XEulnO20#4 z@I;xyMIfpUO(|-VUi~^&`wc7Hh!=PH@5Chhjd*Af=9_pjB6~&Fm??+N=Fr^ri`KOE zW8>mG7>eHC-%s2W&>LZ>^D%RX;I3CrG@_WqbxzESlTM-=E&8S7rE+ItYfDO(bY|@- zvCWss!{Cm1kL1BB;)l&D+ZG>o@U2Y+Urk-ipLy3cLS9=M6 zjEPU_LAjM~Wp!a|K4<^jFtQ)QAVN{CGIV&F1o2>nW}S{b%;x`-QWFi#`UMvi2x4HT|A6w`8}K z!ky|+7*`dB^`a9$ok#@V&OK5es&VTHkkprGsg@>2(f?ydQMBto?atq37gFD>miWmH zU!~A=YdTe9VmurB+IWf6*Wl&?6AnF!aj=?2<=wcM67bRa>w1AU&&%sAj{R>Fo^6wo zj~BG@j_3`$$SNw7bKEawsyWl0G*EvX@z}%b*FdxcnQQy#A#Ty*_DrKn*_Rf{lBz^I zJ8k~%?@pe!%fAn}qc(}ji<^gzF%*n*QO6^M_ z8}=q|+L6`fjb6v3qikjV4@akpx}4>K=hk>v44Ajx677fCg*aUux3kqJV4I3kl+(6P zFlapb-b4~%Fg`N=YwIfJWI*J0fF4^cc4zpUk3o$z&D>SL$DO1GMRWX5=BBbwdQC~< zx`6N9m{-qS>)&Ls7n{`eDb9qMA8mohhMf{)Q|~wCQ#YJwZLybpf<8`1cepmodoI7? zQS1fuq*-e(nz%jP=Wb)QDUseoKaw=S@lq$pS+nFVC{Gxl#*KRKx>Yz-Cc`G+S}Y+> z7A!4Sx~TR;L$6S){Bnu9*m28g{9xVtgSBlTrY!nZrYk^gfu{_;cQ&rl*5^#* z_=RiG4tsfT3r=8tg$P!9=5yr*rsTQCF3rF-rk({E&B81hD`IlgLY+B1&$rEJRB)87 zun#4St1im|tTz_+zJz(~xH@Tb5;|EHo&`U3;f?OAkEnjsF?Fwk%Y&ijmoxRxbf!&B?w$Ya7*l(zwEHBcxY~Bw6D!T%Sqw;U>O2bllYG z(Pd;F5FeMH;*vKYcobH2U!06Ak16-t3%z+BG3m2m@f8W z^6*rD;rWJ{#ZL-Ee;1b)rfdvhRlXH-?wzz~R1!K$$_6uI zbZa8^whsoeuRQp8Wp#=!$)euL=_umF`RCcVi%lQY;CX6r+#x>0=~lYKZS61;jdg(% z;Fc+S^-%;TMV#sK&mMW6oyG>?`Pe4`DIC`=lfNG~%%*cDH?9yx#k>g)YB3+WwOQOg zx1JQXZ7y>!FLBKy@GC!ci^27MQSs{mfqd?F2tG>q&FYYl&}qhJj|rS5y%?hm?qa=s zeYDdjYE88~8!Ky`l)dN>=Fi^he@Q$kN;qLe?_-BC=4gAsvDoT6bTH|Um7KBJb^y*( z6y!1}VGF(a5gat-s$av7^u;pV!&+B0 zdf>Tui;8fY?K1}4bLm{8+4TV|n@H`k>-LRjsA6NiS{3?N({rv!NVl~-e(N@nE$pr= z7FtnII+BLt+@RWVBDhiH?&ju$=l#Zn_3&1XK<01LH-wQf^No}hbWd!D$&?;Ngc{6^ z9BiBguM>vjSjMHN*g2iI+hMXf=?#1=MK$Tm6M<8J)75Q5kT{wXLO~I|XYbg}osd#9Gnu)4J5(3dpaW_aP$uKK3ZE+)S6uG+y^v zxW6*PE$#ZQ=UtBj2QF{O??fy+j?bJ0zo})Lcpk$Wm;1Xvrc$si?9tlI7+%U05xoIf z`A0u(24-sM()|L$W4`5xngq0#l9~+rG!%e9N6H^`e!^6n*fjxzD`UF$@&dx1?EJf! z7A@6}3Q3y!=ZD4^iR3L}I2^@vO=&}&yU4C`3D4N}{XxN_Sjf~i{c(cBhZ=YqKQ>Po zpB`KFAuo_||6Q~{(K%Lj-I^b1{!^qMH4^6=1qD6H z$iER?ePKaX5m)-SXW{ot@8}5+K1UCgUsxs7 zL8P3||KT8%C+k?yqts|Rn5wY%3yzU#_d}{U;6wd6EHqCq9yP?7y$U9iWA>! zug=$>&!b0ZxZZ5rw7ws{b2ff3_Y2o}f6`@WDsXaLHN_U4r^F`SciA>@*;Xtx;DcFG zRgrn980Zh}yu}Xuz5X^LoZ|a4fi(6OG*+#@3%$;m>SgzjIBBQLT~}Y>4Xm`#aee(- zeqA;y_GSqQgJH;Yg*~UOJx5XMq;%~00%65yE5Cl{`MWyod5eSk0-DnY>~8!@z23eT zUy0GS_9D@eN(&y^^lU!=HG-{A{$=S8o^^{Ic5yw$h2zTU*<4IM>hgrHARgXP(0D_A zfRS0S8r{3V=9(WfwOLhJOX3`7bF0sk4U&l}rlpnDU?kYE@}`m3S1OW!xMhAm*|fy< zC_r7iUg}{(^6*j~YW>s|+E;GNr0SkumoSnk>c?LdTSqQ&?bw?8F;!E%B8bEsE$mwu z#m&7?5Ha`dl8?ZDqweCh8AQO_n6Kz0G*oLn($3s;!y?N*?pg4Sz=9t*Rp>>AF=83w z_Vl{^SziL3aPG&M9uK!_Uu-|q>%ZH0{08^({q31qtl#G^&56g0x7O+nuD9R6ozu8p z{AXn5DqBQ_cqEyU(Qy<9mFkSV9jC+5zp((1WW(nIwO0S|;*Ow(**&}G*F~oIB+IX0 zV;c?cz@6Q6jFo(BEB%ciYGwVBGbtL?!^iPHT95ko@*U)^W=N!dt@R$*l}J|~e#^Hy zhN7!SKos9c(wufhcWXZSthOGf^GE!4_X+n^%TR4r!44gs$Tm{aAxGTi>fTvjs`M)- z79W*;-ddtv8xDLKt(~T>?xo*m)6Dq*ja1iGjMe1#gE(wq)0Ye1l^&oGx$JSNwXv1N z1#(A^)!Um+)KB!SzYr8(YsVe9-vwL!bR*NhoLR(-Y|*s-<+Dj>avhaTbvWL_s8$T0 zbm=+{DAbBwoomScus@Pxtp1vpwI#niG*RTixz=&R+9V<8@jWi~fP{`bcV<$qk>=D* zbe-$(4=b9}S1BT_KX;!hIwUH}DV01g&pi0X#XGh2;df`|r+Qr8Z&U<~BL)*m9Bnhd zHtwm+d=-|})y==O_xKgJQTDjr^47<^#6Fa) zVFo7)a`HR^p)JcFqNlBLm+f}@&R#RkM8y;QU=M$}-CC4;q;Kc^)Z3kUe21p$p!9+3 zLdr0zi+ht@kET1}$8u>Vap@$j$4|dIq}B?Qx9O?8Jg$?cmJPZD zE2MoEPMS!;n^Pq!`RFG{VNGsS`8E5EYIqHWC{_u@Th^%t-_{Vy;IAC9wDL`hp9TMJ z+5AjIQ)KaO)N3vHJY0}@e-JIl3QetSAS#ARGkGZI$!Uw=pO0;Cvz`{&8f@*^T$J%u zoBf^Qi1faxyIA6{`kc18%rm?8`ef`e8*TRvVYcX%!7XL-Zl?11Cxy7`@lp{-MYQ;$ zJKcCF{pC&56Y*z6b8_y1Bz3%Z6^pOmB={A3)j?38yo;V+zBuDUzthp?5X$94 zyXOkK*Fzs9zj`F#GoxpOE6~tJUsI%S;2`ckyTPr!5@@c`mFcYD8S4FQ>y~jIDzTl| zdi6PbvQA<3LMx7P=Cf?(7mlXsra?RCayBnX;@8-WV2czE5o7UOYp1zS zj(cuXRhcW1VEnkPLp$-E87@YN{S7tZmr^4I(asi)>`iue<%3E&`CS|-oNz*D6&)`| zCcW3Hu8+=*I?OADsH=_EW1|?G{;mk1FxEi*x0c6d7zY)cJSAdIYG< zss=23!VG>KoG`zz=qM=@Kl^5F@RbP~AXB<>Ht!xD`3?nrLGkD}stUy~KZ;#-^L`}Z z)L|FsZ1;`gdQ^|ap0`Rrm?Q$mkqXw&hC?L*t z;$~c8Jq^p;*5&iDb6V2nDD_>tT^jLZ?&LOOb;AvcIb8wjl^gdlEL)XK^6_!So^`E> z8Klo{A5fm*dc0fN@_euL_emwO2{A{d*KzCFl5Akb&?p?KkL)}&;LT2J9mNUb|CA)< z=;r3>lRJ!|j8Eg9##rWxNia-ev29eOLtW8icFyedM_4&gG3Q-mOF>wOSI(gVu}=7x z5tTCq6F1WA7c_&7WJfo8_nHzX_CDmkNPbUochl=-i~-q|_6w}sC#+WD(XXCKCt+S^ zx7GVF`&}IG_*-*@+188qFSKWU7fP3mAJ;ZC83#6;Es!=YaizrT4hW-)I#x+Beew*@ zla5fb@UD>xzuqv{N8RtvK<_|r{5L%DY;eJwJ_}WL{06UMpE7DlP%J|x`I`i5dA^_y zJxS@LXPBkf{wvKHClZJK3!`k#7(Jau>d^+Jg%OYV(b%{KjbFYM%SfT*iDxeh_L;wm zH~EoTrXs1!>-~^mz|sXSo1T`>_gC+dw@E8M<^_@`|J7E_Q%^~(=WWkfOOsDTcYVO- z#1cVtt$t`GT;&N;8T@_gSr^6KbaaN6rWeWc5-l+^xz>3QYdY{6rykRC??C`>ES1ujU&gzANAls?1$S%8kE= zY~>|vx5V8-F1WVIf~I!096kS|>>91xwoH6BCZ2@)uQKfae%1C_7xEFv>wmMTsobTa zs@3W|?b!~}5nX^e{Cs6p?w>mNe_X8%Ko3=bv)Xp6lX?G08oeYPw>ymkKzB6M)V=^Y zg>tR=;I-Y;bA&4eEPQPM8geZ0?e=h1ZPuSE*I49LOu2;u$WWGly^Q11$k_M_8^Eui z%1H|1JRhHie*jl2sK2s#p6q$Dl{k&l9+6!(u2)l7{GN(fRN_wmjCkWG6<+7*_~ps))wh3`O~es zbuQd-Un@THsLwD%FhMoFAY!wz2dI)@aAAVtIfDD^()5A~zmDfV5i}AXAkQ8+qzQWK zC@S897KE#F*!>ZA*2$N01w=+}k1N1xwvRo^EQTWx-!QEqu3^o&i~9F#MXUEYZKedk z{PG1{4gju;RF_8>AUO20Umz(d3G_Pv$;p6HWY`HhAGf4Hsp-85AW%W_RZl(JK?x*l z09h9dK+&1qamzZ1WUe-~hT->k-O zk&*rFEd{6tKmtt%KM!grGyo6^kbNY3e0-0Dj%LsbYBnCf{G#^6!^0!*u;J(r)-3{p z(IP!GU?LJ(iyd120vMT;nwnbDuAz}pvB$A}#UyB~OvA-gl2(;^0I~J(l{jG6{36KR z=MGQ*PB#codmi2a_~r}(g*!jlH=@));M8!_$iT^)#x8PNe+4+h<&ol#r!{~W?yH#g zdJf>ds_tjf(z75_wOG5`I|sl^+wlXy0>-*~jPH(A!5^J@dH0)`^rig(Q507YEjk8> zCMt-*h`s5OF8jxD5k!~Xf+?yZFwzt^2j8m8sIU9W4Ruxrdl zSrrfO^m7aVlR%(l0D95h-X6qy%;d2(G&cc03{%n(#_YD&0BF5<7POa{{o*~Ga7bd) z6U`&&1{|I}^uBUs_(NhcvQEGkJ%0g8e@h6)7m#__A&!6pQZLp=*Q$Q42cT(j35mF* z-#r~28GwU66`%(&Vr6k3@Wr-V1j?)@U$HB4b@uiyra1Ic16~)z$_6r?_$%&C*mplf zK&M2nw2gW(zzvp&uw5>hJKUj{FA(%y;$1iR0RDu4v_8v9NJp{HAey5Yc0Z{KqI6wfcFv@~LLTDiT02uA( zk1Q=fY=Z}E$GIhFPE~HVkq1XahG>y_j4cjgxP-BLY&BuPP$-7Ss($@f6uO^xAy&x+V6TH+Lo;m+^Mgr$yph<2 z!jSs<79w#5jS0qpG2WM_(}3xJ2eLUZNG1Y$rp^G~i~#IFnCg+~377=FT0Fb}#M~2D zeg{>sK`;tHrGP$q>f*wStVXZ^Z4iwB4lxpr;Xx>3|gc-I4$XjHP zuKdmugODjFupLlyHrpJ?35Z~O2Zz5?bv5oNAGL4;tSOAJj+9NNfCY=3YKS9ViEs4+=(B=!~YfmTz1AcYVhfM@`y z(U(c9yJHrG1Ar=xXVsB~I{?+2e?|s3w^O02HwsJrkv{kpwidTjcEbp4hNVV%<#P|f zD?9RX)1EG3*o0*xxM5t2rU;(K;r1jtP*dQ0v@z_R(7zqf@n)Yxo70sS7as*f+ zxOGzcKm_gwPU)dxX6>BDc5x{Hdl6*zBb%udfcznSSz!ryy%&I|EoSZ(Bwo3vdMYDv zn8c9g;s@bygG(Ni6O$(cdLY6A*bRYiBkqSVYB>N#4)&?by$yxuPaVq_TPR^cC53o| z_r8$E(SC1v09K7{Si1DXGGT$80QDz8r~L#7KCI_Y;kKdM0AoB6&IPdx5|`^^tN<32 z5Es8He6}T)_l=L9{+a^60cR69N>HU43L=?QZZ zfPcT>(=!|v{~UqQ;>M`SnNr#>S^jXam{k6RF`5Re!_Be+1j-G*%?lw93V6TV;;>EJ zkL3eoHZ0NtTa9Q>Mi&vhG)?|knU8J~UD z=}BS3g+5@nA-xMmqz?A>UPIUcc5N9oBTf0er16q+4nH zIuII;8ZJ+TA-8*kK>M9N5d%^%g4B#9D`jS>oVk+bd7zE}1}!Zu^QJE#DdK?SF9NXK z8RH_b4psun8Enm-B^ez6`nwdu1Hgz^y{JT>=MZ!#bQLRz7%*$sCPI2TwoW4q1I2j? z(w!&*Ie}Ifs# z@vt;vjtv+Eh^RybgAau!Aat|+g5M$VQ%K^DAo=5i5%=KtTL`)z!I3vilG3#$6&Z3K z0CR_lYS+uRgb!+FfmB{nPEOpEk?mHyQy#l7Ksiixu^M2L_5dWehXCo&{jlPyPF)c* zqF1m1@L$Dra_|iW4Ln+Y{$xmahf*)V11&bl$}bh(3<)_jp2}!y zP6&DHNJGkr*v^9O@GjD;5XB*NXaU&vgez0&wA6kZB@$Rp4`CVT`Wn;9;LS5(i-sq3 zl)*m@ORdX7oN6MEO&Ylv2xPtiBj934AR#HN+NfRyyU3g1gp7%}A)k&ylmljkob8`r zhU)aX*|+4#bQF7wJu7^9ESDG4`h;`%$5FbGO^?gT6HJkP07H3K~ z!Hk|BC~!YlZ{NA|Bj_gW#KA|HXV~Q2YeipJ179dE!oA%C@eRgb+H2>B)48A;hNQ_+ zQTWbocDf3R>c2wHA#FfbJ0QzI++9kOiLcc!*K59pEUjA3ldZ6v`3X(exjVy3}}5~ix3b>F9sug_X{c6LbFbWsq7hyRtOcc>#sE(;ttwUs)92)?(FT?8XjoN-iX zLaU_GeH3#@XO{Hd`gj5?$FbFgZZ_iuwcKT}iq--V z$a4H!I4E{zxQ4NB9AG4J7=?^4P=r{YkHCi|P2T|p|tv-tXCHNzWGZ2H) z#bAUG=W{-nlElh<{TQu5kG9PK z8bXpmeEl3(9kBc47?J5`IHIQ!?kh+D103p(yPF!}G8$9?7%QP&Y{-A=<`JxMS z^)`1ofdI4sUCQD(`^}Km5Vpy%WV-_SXM(%_$nX$0(T)ZWTBQOb=6C@&3M4{=wg|*N z$X{o9aeMB)M-hm$3Np)}wSa_qx~Y0r%KFX90Ww;Uk2%W- zaF-Y^2l@@{3X5Q81o9i=KuSVnY=Q8?zqZn?XIJ)K(48Aka1Wmf(vhT1o!0{rnzl{o znr?ryG4=!{sXQOa3GNeul1uy2?oE)D1v_msU{OFO!>sWw3>JWxLTm~R*bV7BnjZ}t9x;Fd|IcC1PNq)T@BtwmWPgL+U~Omm;t! z`C&+ehTEPvzrYhdlaJFnm8m+F*}kC%q(*4Vy^Tr!<->g#z8F*<{esc;2V)jyUBg9$Nj!qE-(sog1s zU%0=0C{}}hB;$8t?t`@Djdx#mf_4|I?8~?jE4#;tCMldS;l1C4AL+uL2xwB3y9oO) z#p~qnhR_$%yc1-xhtZo#<#pr>!`kk*f@Cplix1CXt<3JJJcm}{h5YVZFKiZ~H3L8Y z_?`LlA3W1vUY{uqZ9bQgnFm_V47BGWgg;|o>>9D;e*hEcB$xxR5e_3|_d|jhrG6k9 zY~>%MENYb+<3L6^s7*HrX@TQHgu8u)G7u`xx8`}_4CVCW6)?d<570sf<-J0U`mK>{dnBNN>GVpnRUe&lUx6D0usMLLWf z_J9FrXCA;i3P2(~v8w49Ro)jkH9rr+dyhsd~!4BWYf>;{0}alp(vdM zt165PWpl-^a7F-(7QX*iLJ8A-M28hd6L52srJt806dj0$U{vjYq4nPxF!pS#H9)tM zHEJ*gO83#Z7bIwb!>cNt-PHLL@kYo|g4oIA|N1v8r(SeMAqz`Or4CE7JG;AZg|S7C29NoO@e)$wav?#9l(+0Te9ef&OrQY(@uh8+GDS6d6|i+a+SPvB)ZY`RuAEN;*` z<<2}Jzee=LE%4#)xo-hWO`{)U;p~MLNwE8Yj9zbPbCOA43N=E9>B88VU?BxlYdBvX z869ov>A@x@CKh-foBP|G3%-CtgilV6Wnp2#%g49SUC%ZZye-?}!k}MEz&;hc^LPq9 z?it;}qe>MfBCuZ>!Q5+l6BsC#DeqAf!#|fwx+xy!Ow-1Hb1rq+v(-J~9S022sCzeX zad8F4Vr_t!l`&vCbsC(yE&K2H1#Qdj3C#&gI&?u~XdD`fmrUA;%g&~QW6r5lC(_OT z{A{@PCg+rcz(AoU%aTHl$3C?|)PKKBy?&IO&Oe-=e;!n(3;$nJWC4xd=wIC#w1XPM z0sG5;UdKMtWdC~q2am=q?0>yoK}c8e?Z4j7NX2$v>|bw>#hVwnDjutJxH;+17_zms z_sC`QZ%nSKT1COYf@~^3h6#yDUCHWjf!<-Smg&$3^fRVo&;zE_Jy{lTIU*&X|KW2_ z`pB^*a%+#V$zPO8=9wsFZvk)U(r~)i&u6YBvIC+4at2h&)=FgdrL})qv`?096<9UI z=Nq9bh(zy5$mi=kOw>p!9l*-)n-Y$?7yLwI6JO+2mOz6~3%`5x-qzjUHzHA_@UPy$ zM|sjOdL=67W>gO9Rn#K37+q`nC-mmjK|YKqelh`!=F}BVf$9UsY!9YxakRhpX)LDP z^n@q> zQ5z1kMv1tpv4u%s)3j$`-|2xKTK{F)rWdI%__D3Up;Pi+L21X{#{M@JV0j`F=OU3W z8Y6frAMF>e?$W2!O`}PZ_>?BhFD{M}*hl4uVlkO7X0*onRaf(eif?~*{PH$;I9=av zxb->~Zuj=%3(5nYF*)jB;obQisn^;Toe@bdx8={Cx^qPsFLG^zqhA?+K3V|MS~v&eYjdxMbsm3 zDz>rz@G7Syws_v(s_gd^2vrBB|`anM}h%#RQpYSwi8-tfUXqheW* zwxsD?{6o^6^@{5gb&@v6(GA$`Z4Z<8ySh{vZHn&b=$x1Em0pT?Xr^c<=`3`OoDIM9 zj~Gt+l{;|wt1n~F92Mm`F^lL&`WIX6=?;3v#%)jUeYnzHQOwH3ouBy8NdLP&zWt-C zJXdUH7VDFPaKZ;SiB4Nx zmGq`Rp8`irJeKS$6PX+U1HR7@xRBwUd3F3>ti1(Pl|k6HyGd#3PU%kR77&nb>F(~3 z4w04)>6Vo46cCW^E-8`jKEwB&KiB%!S?B!6r7pp}dEY(r%rnnBGuM3+=l;m6T+1qq zl1chzFKD9jJ`Uw6Od7L#rwL}3*dxG(0%r$B3vFx{nZl*~b?HA`1P*GVQ)ZARaSW%5 z<@z>5kuQhdP`**>R3PE9oJKguW1c4Z!Ld6g!rpC0tWlwr%h-Bx+&SN7h1KGIP{ddU zJcl#ez;zwMpji@*n!!WR z{x%{!Jc?1R3H`fASz@^UfDv>DhfW^IE0*CjbEsJvz^N~4P9#YR#rCYq_&8ag(pye)@#$Mk zx6{d$rwebmMNTk}`^otvbR^fi{OE1c**ZDdeD}MA2gd4)l`Z)YrvWQ!Utrl6I#cSN zQI)Xn5t5UIw%u@DZNhhRAsaFUNRLFg;<0>V7|qi}F^|WFBaM>dt84I*9UZ}gLYYoS z_DEN7)Lj$W@S(b0e@I#OxDM!r>FvHS9PQxGETp|k2L;|wy=8+{ctUj!8e)##?cL3? zTU`zs84m0YLL8H-M7CNp4qIlqrPZ_nq~CldSu&V3Cb@ANNgH2;g=HjC2D3`mzlNBt zu?7j+QWr!NLxyYMI_&u9VU)#$p~Bz5qmo7~T_10ZpPHOpUcN2i^!_8QR-r}!rrfzC z)SEjSx$H}t*39Jeh##HN6FQ?+D%2~Tj2hmY@!P-mr{>AWBkft#hRv5F191E7>*pIwxQCx!k72IY*eT!Yx!UN{>s$zGU z1?)k?{ngc4$Aen>7srN#cZJDHBOktaJt9#GYDAChZl1gQ^JOap;<*`7AF%^st=Vy zbrR9X!{mFFkPX?l2)W3feyXKvG|!Lf?TU0>`;&o^IXB0gd$)^(Q2q?y3^zhX}3ig>)A) z(;c8#3S?wFtCnt9`|lUu+8El~$Dol2h_j4+%2{ryw_$f$_AHsa=u9gIZtTfb+K4VL zE_y*`H;N+pik*IzYd>I+a2QM{{a!YxcVkGxW1@QIf;%RXp4ol4cOKc_S5+n@;S`N! zg-S&eS+sNjGd82U#L_08U9_;L3#<;po3~q;r&%`FMmGD@Yin{O&v? z=K4cxyVg9n=X1DJ*VV?S%BjcA^g!J~#*b-kZjS%fn66FM>uepAinc2$2afh5e0n|N z_AH;HGf;^m_`3R*;3S9e#C{<4t?SQ_%%aVDwH)Gk=o@++Z+p5vDOV<6TkQlpH2Ncl zhV~TP(Ie8QYMHzd{O+AI7q1_64GpaJ?n%8i7PqhqN~zVD(}b%e)q>!=$5fQd>M*Z+ z)uh#}HngP!b;hQKM?_;f=YbUq{k=_D1 zG)rpc(ShZjj_UJy96!?6@jcFMF;OyU-%8Ya&`5>_a_zhmJ=gI3k6-!bnPL8f!CuE;m2K#)QOyKG}t$Na$eha@{?T!DjM3f%IiVvNaw(9#HJi7Z^Pstd z-&uKn70Aj;DB=letBA-6^ZlsSa2aHf8&7BU5q8#j*kjS&HJ-4sC&6)FhKWTmdf@aG zyC-RyZC&NEc+AsNZnkw zyGi%MtyaBx{ptgo7d<}%lE2+bgx}-+fzX+Dp^DK|fAo#Z6&cva=CIPce@zzjxtMk< zbN8C5Z-90^vZ|xElyld`Eifl;@M~P6<%O@!2gVYvzq)P(Hgn%&%p#uxqNCjcZLs?9 zKg$f^Q*tKcYIJH_o_;{)Z5G%*;>00{y(QV*dOE%5({ES{^DCKXmuk4=B18p`kuQBr zV}^hHynpU9xpm&u*&skF1ugPVMU=~l3kjB@H<2T3;p^O6?4S@UJ1pfMv4pIsuOH&t z6@G_f!lvPF>g1c-hccY3Xj@2!eIy8OYca~}_f&z0;QHR<&wPpxJo6!6m^M=>BKKI# zpWI|)Hw8QVLZ*&u&N!{{d+K%Ui!C!@?Qi@A2FFZPe>`m>KRErR*YDr+r=;dgF>&q1 zyy@r=_`UtSLLcm@GL|-+!fN7gNFGBh9CYt-oL(2Dn=3kZv~n`ixJF1)an_-Siq!Mx zOTx+V?LXFJy_Mzhf{&_P*30-$z`kggG-1T^nQhkL0okNqUd&jeMZ{Z$>nN)R5 z*=HImQpn0^;hQ>$>6(okg(YyGxYtlOoSYLdMHXnjLgsaB9371jU<+vr7=`Ay_ zwA9~(t>}hyzEj*!dc}LQImgkiJX3k`{BWEzAv^C#k0Ns1{Q1{BcKhqzxE5-*O0&{u z-Jmeq%eJra@j>SnV;eU=OnAL-*hoeEZ0lA-=o2OiZRTFLI1o5AVoG8>&6MRkCD2&T zH(|&o(sf6w_22u{`W!>aW!%ypEw?}GH*Tp9VfFgm;8y5xZ;M&9J&#eFtPQ^4*9E|^?E6BOcwja5{uV{s1iOn z#B;Pva6%-QVWIGA2MO+j%Q+W{3@>7))~%AJ} z2jXM!o9xIVG`Sw}rGz4T5*3TccbuXIhdp^t`p4hBL*+k}8)BnRm@0@$Ooz2I=7Rm8 z6OPMG_GwLZ+WKc((W4R^q*hG9H{X_9!V{ zoEX*ZNw~xJ?I9I28B3|ARZ=4e9EW9d@%C@lh4AdyyzPv7?FbjDm=ZW3LrK!z>11T$ zuC~Y(WAHR)PX}UI-<9`Y(Lgv)S6CmYWP+$o)>OUPOGXfN-e z+`cBJhWAufI=Z=qMn-ZXwYKyuEWiQ3D@Xc&(0F)wy!?E=*objIe@aNSefk2dCBcpi z<-b1Ukk6eGo?7_&Az_=6^Wdw{cS*_cnmjzjBEAu)AWR_ZUETTf^%f%OJT@U=moSF* zPfp7_$?54~lmiNH9M>r7tS4aQ3FjS3NCW4Gl|8 zuKnh*p4xC{2`QO`TMT{5p?f@xB=D~(vgIGhvUYFH-K<=r+tI9I6K)HXOb~JO@TgdG zkKM)U+T%L*j=oR;T@oLVr7W;cR4R@bFM%y6j8G~JA1{iyDb=8U(CuuKnM0Br!Y=ph zd|OMXCc`@6(oQhRB_3n{ibFcwl(^ownx%Vvwj@o6Qn_Bu+S^zvOZc>kZm#W;-*-BS z95P0^y~n!>L(jl|Zf9PT*?9diAByF{i3q7ssK8ngNv&;I8G<7+j-}luqv1)Xcj6$_ zyPMDIdGcCnO}`>TPYS|TbWx12R?|Ll$)eqkpD-Qr;hhfL`&7n9u{aL`TR zZAPNa9jddz6KfbPuaY~PjprmH=g%?tH!HOIxlFcN3E#cHh|Djx5${_f;W zp1HD8_u1yHi)Fp`Pl@+i?v9QzDV<)lRKo)nor>1=gxhT{{>Vn4*I@!yUFQ?xf4D6v z*se6FYML7v#dKs}{)`iQG_twxWRGV9>R~Rs%)Mtb2XXOvYlu|_RcJ)r1D3=QHrGRE5Zs`3I_+c#ULj!Exvnyy&lWC#$dpsBGK zNe}y~*9*PPDBD}Mp4*(S!&eIam@*l3;-Fg3i7rg)-?bWxn=g;51ZIfL{QLs&ot8Z$ zs%UtKVc%%Jc>GF;*L%B0)sUaHH9M z&yL9i#jx=3J{$E87|vzE*ka$Uw+Wy2gmY3tAj*pQ)eq;}O)fVvgTpp&wV96E@RMVb zZ}$HvI-WZX*K|xOeEH%*Ydq5RmbdxDwBaCy3zJ4IJs@}BbDpcH6*oSCnMMWDzy8?I zhVZ|1GjnXscxgi|_aPZjuj1V8U<#&F&`kppPw;~(Xkm$1e=#V?X9GX;10A^!O?81u zNjz%k%JYkOr=?$ScrMtUn(+{#3j#BnakPDAyEb+y#-))gsrc#$p|nvapQ~`$y4GIh zLkGu|&l+}x`D5^wkP>}e)uoI;`X!;Rk%nz@n<9t#hM<;%K*PwCWVC_s6CwJuxfS9# zG}&B%e1&QZKWt})h*?v> zO!q4s0h~5uhtR!uKNboH(_5Yrg+RG32j0vBW9QtV-pw_~-6uYLcJ?2FHy0w^xv4OI zf)|vK^)(>8I5egyo>anNwNUVjDvDkJK8atwr>g>{w&Hm|NO41X1jeS5?a;l5z|CTP z_gn-l2?-EIpkT3}p|PEp!Ty3uE3-*^UKrHolzfd7Sv$>WM#lSA?RrJnxm|qL<&RZH;RKO?tfn*KqK&uBK z=Xy)Xa^E6)Kl?K}h8&>gWrm>*TSt)Kldc&U|hWu_yE@UhJ`fAJ^7Gk;3qF$Nob8$j}Mj{w8MXPA^ zln2Ll-<7Wbw&az|yxj^43wnO{Km)f+i52flrAB?BCJPt;BJgZpUe1e>dQtDN1da1) z*!XD^XZ~&ut>kUmm<%{B5P79199&U>`n1>ty*-pXIOW?+@QEn1p7 zj=_TM$_F>NU^{q|=^16mQ`f;7d(lqFJWfMzcEvN-e&lzn`XVE_U-;Mz+TqcP-YQol zT(|olN{cPX53gi@{f07&oQd;sgM$@yYeVGXKvjVwuqD(DTJAqt&W^vs4BX8iSCEyA zCx3tZ_x-xMnAx7ZKS1BR zmEK9Cbs|0+YyWGZ?roJ*X26fqp73XGBMhaK>8dt&vec#^&^{~^;Mb=%f|W+65JzQ} zU?#VQr7A+$-5CNzIG9ot0sfH_fM`4<$ey zm5Lk3g+JM7OC@$oKM@3QQl)=-f*frzzfDUxYuc+^vF~L&*z%I$a zsn>v;8F#4NxUDBA*FzD7DbiF}c&n!SKzaDbrjBR+o%*a-6%6;Ze1+5n8*}lUdB=+# zB{O9j$!?4d-^0ZO;ElwV;Ns#g&uQxjncZU-qV+#`{|Ka4M2rwu;%bLJJcWpod@jjX zk7Ghm`sgicqKyCLyOumd+7M)GAO2b>>DYHOq~W}+kcwgJ>holj zv^9gTkAIlszkcz%yr&5W)$Pd^GFJMj4^trp3tRC-Q*gl!$&(7vfK&=<7@nv!YGU0H z9tBUd4{f3`$zYC`>8sr-5vB;k9Y3g|XLtOmsp707kQ%;VUWHZl?d(ZvYPEE6$5!E> zvcAJ{h$!taFl$~7E#uykzpyd*g(k#bH`|WZ>_hfS@!O#LGl#;(eZxqK!FsJ*cUAw_ zLZX#6Dm6mH1T<66h#)%S0X#p;zgs8S$hxDeS)3Ef%JZ!rtZ0WK0YJK0v|`AQt|hIR zPxyH1m4PqkN$_Y@?u<1mi#XYGXRL_%NIwuXlhYsb5P1j?3+^;)!|U=Ki_Xb8*n3(C zV0vFUuk~^3SKy(e3*)ykyO6N3T)!=MX=&Ntbul0(a98T1h;*L8H8A5J^VJ>=yQRe> z4#v<$SYFw1){(5>8lz?XZYsFOd}uu~NH}ODT_d!UY8Y%-JvAHnQTg0rdoXiT*Y8h5 z-|NYv=YIaP)IOB_@~3haic%uut8?o##i&X@N7(c|Lj(@dqg7aAienVKQtbjs;`hdX z*r{kmBqF%stkW>_CP}!_>en~tA$f?3_P*$NUrcgi(TTA>m;B`VMMWU1wV3NZk%vHc zgv9>pi=VJ>>3ZhcJD35AvrXsnX1d<1j2dD=U;2#qrWT(3s`T$si|Qjk>z@8Cm2Dxj zyHib(Kn#)tyGrO(OhVS5Q7vpa?YS~&1q(YzZDrG9>Wxoq#_$4`c;W<$ahlEr91O(o zrc#P-{ZJt#wg=J0@;t`l~GWF}}WTkqsYMdPl^* zAg|r`4i2HHvEkG-4opf$o?ghz#-@~kMOr`7CuD{@g4N+>+d)jZOfG_o#fHA={i(B0 z`sv`L)Th-lo}(4{?2@~T?lt2Rk2Nuxgzj2XncrCguWENkp&;J`e9&smg; z_q}4*a4S_g6Y~P^g}zn2_CJzVJfDFh$sY`TY^G!AAk7cEH?z+9ec^vRSdWGGHx;OX zgSbDoAfpFxQ`RW_o_@?@AvN%h!a7&1cA^`=7B0+XJ6&eFwuLlX1k0`A!D8%;UK$n+gptqV2qf2E{-JX*Z2LstX60537joxQN(_hVL;(=M2i+T3UQPV5MYAt?7OLSA z$r0vT4jocIYV9Qn@bVg7%&$37{^1rUT^?wOCc41naB_0GY4dPMykh!7k3}mMPxbF^ zm#kc?fl2Y|;4sqA!oni)^mL*4I2DD%6SVT?r;~4@HE)`Vfm)*%dq6#{`3#-J*)mtOboCf@;5Rw zEM4v?h=ro~m6x+Q<-NkJF26^7A)G_Eg)J2hpS8JLIA9&T! z;FN0j-olrg?kI&HJ5(#H(T%0RHGILKmVy+#r^PFt^70BdnGC7R14?dwc=a;% z-u2_K^@PQGr~(lv8JYyBc-RW6?z5T3CvXO{}_rpsGrx?`-}t)@ra*2(*O7yYnuZ0dP1qW2G&}T|!;gN#N zm}Vh(`@&j3)2l<1M)v%tC^-wI^}`QvoU0?q^J{*+ohEI*r8!p?Trv|)tvM8G^b`05 zmSIb*YqGisVHQLG>1LogUEDp` zU|*I8#0#sl+i#*^VR5|S_pl7+~}7R%x=BA;McjbZo4%=P<6-*3z4m`%^oV%hKpbLsotMTPZyPvooss$X3?T28A?l zGo@TRIBsYR3`km`knHmsoEA=L5aP(x$;Tuf$`LsF`%@f;xe^Vz=+MJ>!-sgK0IDzJ z-z7_?OqB|Mv2BIF-N~#0xhM6EuH@en&4mqdATuSYsJ$kt_A~8sbBLV6=@oSz^wEC= zUM}S)SudlFwp;jP&8m-BJmU8G>IXat8X`V-C~h`$Vk(mv1Nmr=`|s!5($RUT`3M>v zR=i!Hh+HKdG7kmZMK9Cex>b=0H^l~|*T)uQil~V~m_R+6;au(!OqZUlX?#Yh=3`Tg#p9z7mg{ zOB3Kz1%Y!1TthR$sILarx*HEEDZHN%?LzYdx?YK&BY@KvfI8c|_99zGL*o|Yqr=u} zG=gE!tjN>!J3bPsv!0b>#n}A26ZQlHzo-AoLd)QjEitLkCxq+9uoL-j90c-zdB#^{ z@o{iMwclEAcI5_pQ+oBwrv?~_JV&vR2yAq>`*+N!d8 zizOH&c5&l|Yy*Cp{>~64e+{MFZDwK;oay7fHGS)H!lEjrxmx+|)iO%6U0htV-yxwk zmz6OahuK9cJNzX_i=X^6ZVa%nH5`xY_BJaW94ygnMYCU>&Q4sMoyEOJBkf9$CL-6Q z1E-^Cm)o#{233nWDBj&=1~Bz>uXdAS4LnA?_h8xDeE3~WTyOl4iea-5-j%EW%wg>f3_4P6b zkxf{EajFyxvt{!|Z(a*yDED_>=Mnd{G6jyscAuO&MD^*Z{wR8n`5k-4l%uoqN5~ib zSeoHOtG<6yL-+?&?fkJmRFsa>FvK^H7@r=}p|jgU{?vx%n{cd8kiX#-5HRsr*Dq0H z0QhQeUteF&=&j4{cx&rMg+%srWpuxM#$J+{To+nC*d8*uJACW=z(qwxZM3|Xt^6a1l_-0) z%)kF3Sk{iw>|^n#gtKaT5Atg$ATD#Pc zfYcoMn~^&n%NZF@Y-+EOp;d1r-$F7igO5?MdSF0vh3#;vC#JRbA*~VBz-dRdp&MGQk zLXII0x{I##OP50JS6oZ{m1epWVna_sf#D?qjDO}(LmyhiCci7+oDq8BWFX4RqHtk4 z{>y@HwNKyhlkkq08gjf>ao_zoVOS8A^WnEN=K4mSc*`*s7cvz;C(I!0diFAIR|Zv^U!>pxt1vzE0*V^Gp(m<$R|^FJ%k&34kI|)^ z&l0X`T#NOCes3nPDl$w~^R}x!st2l{iC(r$&G=uTD%;m(Pm)sHze6X%ebjHH6zxig zRIKs+R_{VEMe{wz3pc@zr1g#axHvL_X$!}Dp7(Xxi&>~30{-bT4;dNMv;Hf)f`eG9 z8m|-9kuq0S1v6~W!&*Y5Juz4}rAmlBC`~;vnFk_yF($seQF$wV_bdGZOXN3>CxBQKaA;~z9#&8QT*7Q%4F!i5-{BZCxN5CK)dg#b-HWQIP1EM~ur)GUwq z3)xG|=oq=##VlNd+7tbpZS~fT1Y^7OYw}kzw zZSlnL;r03yKob7**ZN!clQ_I0vtO(`;-w+l?%MQU%XPl;wS2r z1yB+}se|@>(qKq(baj=Gm5rF-@9yq?>9XqTt`|iB?5(uE>1LN*bW+k$_?%DoK6z>k zfJXc+ckl^V2!R&@jfP--FTmGY&wPgiIMR!k5dh!C0Z(HDrX4`^k+ZX7KmfSo3BYdM zYZD8L&%h|_APG1sy$00s93!LpcWh!3lFK20L-k8BcJ1yqn&he!%7R-E?F~c4z{M>p zD^p{@z{gMBzUz2?@&V3AQr9g2UcrBBjSV3e6eI@6_qBlYR>#E_Gz^T9kx{qQGubTQ z$AN3{exJ$LHq@n>H)}Uyx1?Bau5Us1-pzUSZl5{h&Hk27rVf3B>d1fe$c08(Uj2V(1qQPG^5)Zfl~F*r6M_k@H`{5TR@!y zwG~m|A&hc`0ReOb8xV8>od6GxdwSpdgR4Z5iI4(j9S)Mntb+>-qG=p5g!O?~#|z!| zLc;^@_ua8jv{W!aY>fbI1c1Y0G2Oz#LJD49{Fs;+Isl?Ku;T!Cn1NWbeAQwFEr5-+ zwY33vaClZ$)|DD?|4kmpqzq~$h9?Bx_qFaI$zU^UV_#ie*-8w7r{ikt83y1y_R8(= zv={*FE>{g&TF+fCi{ya!@l2`e zE5K;+(7g%({d(~92#B;zfM_r18>)7{tRHaUzKH2)*IRFZn1Mewn|%$~G&nUgw^hEgp-m)*S0Et7pn$06IF|U)cf3m)3iGuFBvu*jW%15<>Oc z{rzOGvW+q+F*F4KT!$)YxlReRu{h$s;Z6z5JIHu$BcF@BOv| zi+7rSJ}I9wEEv9G#>RQ87C=S=8uYakK<3vsHohJ@`S=PMIXJ``_eD^EQ3beb=?@=* zrSio<*I)bvUO($VW259_ZBZrD+qXEtZjr5t<(KUWfr!U3Ruz}COrc6HV2@8uPrJJU zVK7KYD(3e3IYK~SNN)6m){Y=GSkE8@d*L1KvnRbPWz7LP0^jjJdQl0{i8rKoCQ@pgDM*Henwg9>^009k=>XwLX5_ z0Kx;}V5}=O*nV7YaYN3^%JN>Ld22NZzzjRPXy@%Aqo3gzbRTO9CVVpi7Pr`77YPQg zx%by^V4fl)BLl4jNHb`Vlf?()sW%P}Uvv_3fcaj_9dOv{0etAj(Gi+~fdOz7$7*P3 zkd~K+gS-SC(CLwKcIE))@6eDJmKJEtl(%Yub_k@4XxZ8X${&*r`<+I>FflwPrXRcm zm7n`X7z5Oc1qZ~CE)ZES%?>g=7_PZWx#BMwn?~VVs+pWRcru?m1q|0aQBepO5w4lH zK%k~)auV}pcv)J~4W+QYXin_=&bGC;|FB=-Z)$Djq2T%-3n7B@1)(Hc-egjaZ;IMOOem*5|FGLd% z5O^ubr>3v~vsqnV-v`v30LTOdvJ^mP38_puXu>;y4u$LCqI%V*+q3N^|7T$!;=$AI zthz}J<{N!|{k^H;*Tlrc>swpFpkV=Dsp+6cAo2lZ%K<>m8G^ZKD2dSoWbEo6Kg7hv zgS!2AEXN^0ZX*oj_V1ciF+gu%t1lAI(b-wt&@hLEB4Nb|CIAStYD9Rk#MLjM4f-M=j*FZvN786+7`DXp@;f27yeQr<^av>v=*B%&(HH zim)LJ8s*`@92GPU444N8g5Z#A+SYMlp@5hHHIQP70WD8tdJ(>=4z8|maSx!@pJ1ZD zd>VgAvcP9h6cB+EMe;-|(p4Z9@_j~!omz?kdQ+hHO(%r` zpi2RCOF+~2-kfe>U}J{>)>pU1RYubSOyZV{b@cr)B#}TM!RS{UMaxpQ`|)Ztz@GDf zcPALJt*g5yfXwD~`;?^sK}1y6 zC*-> zRhaj{Z?^WIKmJttN6vgzd~myeS!#tN0U-pWK7?=oV1XO} z1FE_{FwWR4$I06~Pv2;sa@Z|$VKZu=Flts#w;%8Beg>n{Y*7<_^1UMvJVF6n>;ULt zbOHiIu!R5)YEqJucjfr)j%L27Y(N4u%KAW9o&o^>4QNcwN?kD(m3Wzf!^`@iB^hZ( zM#gVKKDeMA5KvJ=K{R+@AN@yS1L4Tm!t$%hp2kc#9xX;Zw+o2h05cu8!AY0YVE60oSN@SQB3E z@YP9CdYLZ4)Z%#gS6!`C1r9u!{`vLQJM9Vd87v&!zCv3+KMQEiG3TX=HDgeTf+F%I zLpC{XKzBUd*$DdHOjeP>IMK33mO_=m{yafWuQSt^ivsx_aYsispq>!|1`|QWJ&*+G1-TLg1?OGG2@sNU z1pg!+X_rXE2^BZG$O94LrY`^n*6OcRAUq@nrcsb$7Oq5OQ&=+ZkvW=)LD{NZq2cia ztV(}^YRs)sYx~bJ{5C46OA@v)AYiss2E`gMHiQDoLUTUHmxsSZ@9acC2lW8_-)X%I zO6|KEB7n53WDL5F!R*x+f%6X3VG}^{DA@Ctj7aa&5)rs>4rxNYf?c-+*9-ad9fYUq&2+KIS0NBr&1Jox_K0ZDxe=Iv6uQFBzk-!w(XOZn&q%$yEp;NoU z{diqxw}cCPJwIn>lYwugSQWpBCGmc`sb=8@5@0Z);(;lw2Id5+5Tz0&3ZSTf4XU3O zL}&qzqbQIk4Z(ELatCVIc_8!$=Kis;+-n9lHgwSM1K@eB=9}7FZ!Zj}+|VU4z|72k zqr(Lhl3vOo@L`^@u!gYO8Ixq;z_hOy1*A3ST9Eht-H{!?%pR$NUK~hxEaf`ad6JPA z@wpGJr%nh+NIgLN?WH=>6L16ZMM_Ev=F1nC>WNZSS}T11zU)0U5PH z_eXGp;DL+;8X6i%F5b&^@^Vq@dtd$y1*QFdUt4W0C!i%cSNuR2zDU~uP@)SIUZA8( z74TpY6chw=y^)gtOW6QIB%Ryt`%+kRG%8TP2?i~f4|n0Mia0 z5P1KeptN$}_Q63U%qz5$TtbONR^Hs5**Md^PHwLpr)Y_&sA!>KKx&K^;L_uT{`-$KYv1iS}0f}Ahj9-tJ;KMzX4P> z-p5OTYTwEP1p0qM=IvvicxntuiBqURyA))iZZL&9H-CGdC9LHbnHSy@itImb$N9ei z@vHCKb{Xi;wSoQ$SXBU=5;)J;mz*P5tV9QpDKsJ?qSTCx-Gl!fqvu#%qyO(%xkPhs zG3)=0o@4y~hN}M;&GuiB@S<5uaKOYgE-nrN3Q({<)eUqf-Y1+TYFB{gY$>oz1s<-A z?Cel!X^THC;PndtHGu!1{qtgJSHB432FVu2J9~3zmZ~{AIc-ejzXqX9j1;;VoViT! zKNx=DEKl0I`9E~@K)eD^9kDSio9SA^(blr~{xMJ#r1wcKH94--3Q($G5d!4Uez>y#BGe3OGa(6oDujY#tquRhC8+Z!UN*n)$7h*& z9L&!ar{8%HaVZ|BGoLy&5%IC8zSkkd$i8z-L=re|?+BMSu)tvCOHZnkkk@pfGi?hm ze0?ATUA+rcy%iXOWduDrS-@m%BY%iQaJUEwm?XB`gRf(z&XGjy>WjA%l}OBvS5=>P zy+PVJ0V0T zYI+=AB!v5hLd~VIQ|17>Ce&D(DAjigjm~rg>>%Z*CJqkvS=kceX2t%j2%px>*VE5z zSaZpu;!+Myn%nyE*ISSr;iN%xeacyb&bV(Uzs+O)M+k-moUV^mxyj>`z|vr8*U@M2 z`)=vGxabD``4eE*QXXC((lh5 z5Y%9H#eEM~pBp{aoYZHYVFnC#^^dmoi%{?sh{=Os>bc%4Oph~NbH5#KXmz$J`e=Q} zJjKDtIAYf+H)6Lftz$1bY!^fq)CG4W*eNnz_7nZ**E2t1QlxLEe7jj=yIJ>q$M*!h zI`xhRT_242R260W?`AQX9GIn)vFn)s=4Ibwrgu%o^NAzSdVTK<(+ZoSH<9=q)4|~&1{1}%(p5@+rf-BL^?0vq4MP_mA zrlePWI$|RzKXqUCna(<%ecz(v4d(GuXP?gSS9g(-8N6#ny0Y_+)AvQEBXr&J&hyU~ zuq+hFMwiTlHFI_vG&NgJ=G|1S_}D_dVlzkhC#ShtU|N1_f>^ei<6@WU0hKW9v(LIs zB2nwFPJ_*y?dMY7f3IgXQ;OqrRyiEq)EDMH=BCH~DMS~?PVC)S&+HROJ)7aKUUF#m z4Xx=Tm}y6B*ACq>A0LLperDztNR}}$67eTU87iFcyWvPz>7eV2%)Rl+Bw&9H@!k%0FXK`$0 z%*0!~goV+)kssd_IP&R+^fIz!p?V%J1vN&cM>Z?5Ii=Yomvwx(d%t&*{&W9h&N=-g zwOvSf^0YN+ErmdFMk&mmBbzI!hm;uUsI8;%D1-Z?Y*&iVDK+3 z+m^dWa6YGC^bH^vMv0_%snBUNdp74+=n>c*4((<6zHzZo@4Gu>VH$gSJt+@dI5Q3l zSXN^EWc@EFTV;fil(8KQoDuD{y!Ma`NSd4vXd)X22h@i@Tt;AMFO%cxEc2k@{H>x& zVK+gN2`y%{xiLV-8}eB}u=>tGtmt~OiOVQ3TITWa`1Y9XArr-B>BAEeg@pZRpTYJi z1sN8R2lbpCSEaYP;97DDKR1&S;|3+|*8JJMlb@h#yg}Yr!nf3P-*JO9Uzug2t6y*C>SWk>B$bQc4bESwa3i8#z^+Vk* zcGGtAgUpuSl)KCtKOFj7%HGTDASs7PE5TO_sD)LDj14mGv|>iFw^*i5mh+kkoz)nJ;_#?~Pjy>C9REmT0%Wbl;bj(G5~&K%nK;32%iafHs1mx1f4 z`B=>o7awM}$Wa-s5a4}_w4Zdh(PE3jqWCsm4mU5H?o9EVCNgtZBb#Djvx%|MUbCWS zxuMW;YP*Uu@m=VIh~AEI`U)R)gG>R z$7fbP1OwZRJEgZ$%-=jU6s=yP7B0X2ZFSVsHPRs9M`+L|YML`Nl|;ox8G;T`?Og0n zWaZv%c^Vw()#9k6_0~JO{DQ3tztuVadtmJlE(6x+=VQuCzEGT9$>{pfzz~Z>h=)Sf z8S$ZWHdOY*y{k#0hekm9?TC$e89`Fik?O?rvE)T?cH(bbaAtE-@dCFs7;p9)#e!|8 zO|Y+9`A*Vt0E*~u98SC`d%cZ)t-k1AqG=nVf3g?Q@~>9gFIbCKENOHnbaLYxdkns$ z4O`#Q-w-*p4Qx&wt$Lv>!70M6&Ya$dpl^z>(!b3dhpLrn`t)|-((N5%wb@RN?yG<5 z>PczCex?t*I%g!|r)QT1AGZG1c~PZG8R49cy)Pr8KpMK5otqc==rI;s1|5;t?KbPO zr~gl>BqGeZ-iYKBaTe~tMYjqvOL0DNzU zW6`?@-P38t0xf5<<9ttd+A$6A53i9ny30#?wlF9;kdKatyIPGIK;#AtcD#1yv{DioOO$xW@^v< zgG-5Yxd$$bT#@x?{6=EdV%9c0s>XkKq4r5#g+3$NxYfUlL!YI=Y^w&S$5GCI3n=*7hC%DhW zx-bmN2G_uOHxwF_;E^46rXO(IJjt4j7+8%$-C%D^UDHs;*IKvy zi(eNC=~MW_H3F}2a2DaMPhC`{hm;n)7rPs2Nk-X|&P*QZEmg(NbP<#06Mfj*gaQb{DLP|Kc1h%O zo2z0>?RLSJ%9(a2`YzvxuaoeEeL6SW;{BPvbRs6nIdT}=N=AyGp0o$2FI49j-o>Wa zTn+V}rwI z(S_&a^*q=5&RkK zuro^swTB1rer7 zJ5?blPcjX-mfr`KlI&ehM!r(r%`j=?JZ4qw_nk^GA`*Dmi?-jm3RtNAOT11jrHf2A zjqE9`(|KhU6iNTEpc$)?aJ#DIKdEr-rXiFXPIksWcxBR_$l%Tv2D8Z)$?Yh$ z2O;9$FEP;mAmb658d#~OWwmfiBxBC{0LJ7%w&BOpup)_^{LwBU}EX@a!%i zpNJ6#NRqh$+Xb8puL=5YHbG|`h@D~O}w<+p6}B|sC6te<=s;I zwf8jzE31NHp(kZOQ3NkP41Z>y5dYG&Cti`6n}E$}QT}x7MNvAndzM)(}IFR(;e?vx`;$diUyLtXZza$)BJ_r%jQ``^#kDy%54`L`bH3GgZnEcOdt z(RVac2)?1mKqmdcMEzb*>en)KSOGVk6aDC`(?^t+7D>!yO%i#Ey_V)ldHYrbU5mX} znOBO99+5m{;vc^JD{YsrywERopImqRt4xR3Ky7K!M2MZzSn8*;JA6Lu4`VX)8P;bS z(?H_P1%pOJfJfl>3i{gu#+7e9p-A7gDnet?9NhuCjmheGc_t`Kwj}aCFFk9D167(1pvk=IosDca#zoLX;EebJJPvhb z4ho|E!$WRpzzmo=!*(!AU44be!69^2e|a)oe4VzhQkb_--d`V!p$O;ntZ{JdtMWQ$ z+OID^7eT&cY_yI}tZs`HJ;Kem zN(ecXPH$Ya>AuSv^i{Q6?}IVtrE9@c=beunh7{tkMaeP*Za3>xO>Kg8l@?t}QWmhL+)_=nF>8`*<3b0GnvBQnG_C1qQyPo`40 zitByzQIkVd5$<%+>;|bIE;7{!FVcW8^#y=NV`BPQ1VffaC#CNRWy$(q9@b;3JsIfI)~jq4?$PpTDM(MbhdI^;U)(dcCV9 zL&cK`N_ubK8;_nB?P#EWB}zNRr=VXY;rDbAGd#SgAnc!S{p047t~+;>iyU#p`n^Mq zQ@MgoBeI9g34}JHzZg}$6uR7AX0M8#;5!bjUKPrXEwAO94#|jfXhi~`WVTxsZGG$U zhW1~kU_TP?`j+NkO06%W_g{{7yLG5~u;dyR83>-O=Cr}4TCbA~+v z0o>M(4C0KY5sX-q#XUyLrt}zJd1X`d1kx`&j!kb4>@~80^mLz^_|Mw79~_F04z~Mk zph>YTSad*{!W_!SNUQ9fU!xc8?Cdf!GZUauGt_|zkm7>y;aZsoO)X&8P#u2-FCVV7 z(Sg^L%?t>+82>1@LTqWc%(@4m>SRoPC5=QvUe*&Ve!IeN*$ zHsP3=BlJl0Mr?_1uM@Y%)-H+9!Z^cc&rvQ*9`v5CCl?>5+#qVP?X0Xzd~Z4VEz0To zJX5dBSzn3T9A>dCZUp%^-Z)H=k)^e`L^!PpN_YmKl}1#!EgsYdXHKDbO=V^18BnTU z?~3Gix2xT-DA9f_?-p+EVyYok{Djx0H1{aV7^C@`a-?EIJYhF|jem)^L&Z|3>(ba6 z`3|*lokz_4_sB;G_tt$gP-=S-k6$P+*x{tm6r!(AsGjU@8>kcxIw$yx{r#m(<{xQG zNUq;WEyg@vn4#PxndY(-%oOv(K<85)+O1dTg8fsDPIoTpvf*OXva_J*;Tkt$qp`FF zRo_y}pfihO zrZ@2`OuHi2W*TXux}r_!EcSo@B%&2fhB|FBw00(?qDnM-X==*AZQg5NnO~hS`V0fl z0!&1@tL00^xS=$m?}53-j3eFqV@g?|()~i-m8Pq^1!g)lN#VV{cCHJ_-zHu<@K4vQ zelcP9X*`9Q|5k-PorluGj9VPI}omp^3#8fTQ_Y}}bpEWgPnJjF%JY za*{|{ZQCy4K}cG{sa{w;D&cg>@!=hagJHquRI!}bm%U^sU!}`W&9POq(^wVHp?c>E+4H{sDUwS^o3 zKfC9ozsiX&@}{P=ijiO0nzZfuB9^E7BaHKsS?F?{#C(w~ENzdK(|#jWZrWcP z4=vJ&3No7x+sJETh@6RdWC_TP`tZn3s@xCtJej_ZasA{ZUs{tNXCY=GIvnagJt?0# zU#KfKSK{{FM7zp{TQyI5Ug=qHN2ygD5wa1+gOBWEPu1!jDQX&Lyod@>=6u)dO z&sOJFdI?(>u-Lu)>@U8)s@l>a386b9PlX_k{MKjhovC|xx1bg6${Yn+;gM>!eaxA| z&rYUNp^uL5oUpm6c7He_uIl%?-rh%d_QAV=czm-HOvlx{@6;@AtKJ2psIMd_P5Xrh zbqQ5GFFq*bS*YKh_OudE0!^YuLiH)1?G-|54<_#LGlp)tULIfj?a0-&W5))o^9eB8 zPaE#swc_&Q;vZwOH7@nt%^oFI$E;?${`r#6b2sP1^G^iz8b;o4Qm5%%6Is#MkLq(D zpy`-bk?{!p-bTIgim=)2xa?l#_*z?X=)ec#j4UI|109;@E0uL(53H>ysY3b@5-!DE@kF^tPDoD8@M~^bz!3qbP9MZ21 zm4CQ)(S>!_T6jhAeXGOVq{uV!ZR}V^#_A8ExalH>d4H&ED=iw_@~c1KV+nLlnh(t> z8bQh1Y=phQHTS3qe>R_a?4Lxyo{^mTmT@<-T3Rfx^dBrw;uqNW5(+z zRng=4i}RAMf&|YrM>xy8Do`a^H6V0@4KS=)ub>^*`weOTe{fxz8y-LB*?8aiEjZSTA^Qs7K zkLVjD$~h(VIvcIzB7~N|GypSu+~rIk zgma<$C^$~Mbu0Ci^~qg-MW4oGfoysDl?p<<=hrf={9j-4L@%;mT~&xro*$hI{d4V- zu(EifaFG0V(p|M9?RI6~jqfjvZ|cfC-Q+dD-a;$5N?AAFZAX4nN^Ot>jR|Sa3}Y00 zC)0Z@vaM6+2LV2EBf+X@wQ#BZo`!dz%V0+G+c52^$E#8qe#>VFOofS9MsCR5rKFb{ z;;=GI^0HgaALFdXnK;!EuO+OoB5QH8+Z%AudzP3Gj*E3-7G0?v$cc8=W2p)9nDt&$ zl{fNJzIr7^{OVb{pM;fIAMvrzGOwc4*%>3I9k_mBJ2qB(RF#LmlC;}Q)TYO1U3`Ng zWI#m)e^VE+(&Kf?uY1EIB$lRt+4C8`@6i5wG-A&$qeNUT1?P4j-c;V4>Zj{0d;#70 zCQN*(3O&>V*`xz;C8Lfx+MEeCGLvd~u6})rEaiXsPMe9NE_JhUlr=lE=dQQorR%+T0t<@=P^G@LtAzXi?n{k8t2oE{O)^QksU z<f^4K}WcrQx}mWB@QU42jYHEr!pE0)H#{Et5fPZD|9?9+wtuSIdO zzae#NRSl8!U?{xRcvB$du>O1cQq$|P^DIw~Dnmc74l-+BI%eP&ObftO6qU6uL0sQI!<5He^R zj%Yoq#Gr2r? zF%{w2_4}ZLlZYc09ogQb9x(TlK=$XuFlF^?((270GR!rVqU3X*s?c$s?T9gr?&-?i zlp#%ex9&`xcroyMzy9lTXJ^$kaWS>-3R3A9e6ub4jr;`W33f+;zR=nN0$RW8c7E60 z>IwXU7r%UMBK%IBpn8z4eUl+;LZ&n~q3ph%4wk4cTtKd`uZOUwBmHN^4;uU`IoF`0 zZAbNY8<%R0dCPUMP1fF@`e!*tC1%zw~@_M%WbE` z=eM;;HmPDcditYDur}+>896SAh2K({?N#>pUOQBjl1oN9<&4}Bl1JGAMyY>t!s z9w(_L6Ny>7wmnH`t}ww|UEHTXsZLFGug2)o$IF`ot1 zVyPc59GW~mxWiaxCv9$s(Uv-1Oz){k`n9XU?zT!(piHJ6%|DnIl;o^>#ecY}yn9rf zkm;iQ_jB8!`MG~i7ptG?8d~3fUsy1kAL9tKb}zACx+m@8+fR-i;(rzNh+SVy4-~uk zep~KVEKAGLx7X^ziQs>F^X-A8;R&hrjXy>-_s{V%GY?QkZxLrLpo<+=*NX#eqGt&y z8mR)aHO#u$>GA2r6OPm$@DLua?hiMHvR|+5-s^}wXk0G%IPvv`Ck+9)|D7`-Qfgq| zPV#Y%*lDb_kcp!}#(Z4R{=y=yhL&sO;kTeKt?9$){FaNnbl)F!*O5vbJ$1Ix4=(O| z{@J(#_irPb>d{+FzYhV~w&8ko0}oLFid>iMi2hEgD4T;^vuy`b3!86vJ3_v(E`uV3 z6lBWqJ@%5s1cw^e!}Afx-i&eShCAB+{iF3OUN(aUWlu11+g3;JAPebn+vrlyMByJU zTdMUxu8;I2Vxy|wpG(cxqZYOL@LMMMMvvOHxnVNmJGoMfxyf~eOCw~)BXn#}7`rmU zA!F;XtgB|k?xuPH06^xoqcog*WtIRVa*f3!|INPmB{9Iiw4QNCUY0n&2|Owfn;PDd zka6RAKlR#pz+s0~<r@%Gpk_ z-N9QWX=wR-aWfF#h3$9g>n~<{@|viXnD?9AVb?Vp`>6`Yfirhj@WefFaIWFWNQtW% zm)J8iUkmPgCB7uVY(SEazps2JmndwkP>NYhvHIQI^jEX@#g;G7ZQpCnVlmHDSVD8T z?;H2e!TW?pIlvOny}*p(BK4N|M@Tc_3rS&YcBZizhCdY0z?#f)jxl2;9cnc?~f!V&s~XzNB8ebt4J77 zP&NDaYX{oLN>CpB_v<;)@bvfp^GE;h545ERdlLXmKz7p$C+y|15?Wxs0w!tvSu=Tp zGUOp}+BKKwMGh9CK(ulansb-NizzA)z?Pp_tRmt#BTO0+8o{hFJ`%Pg|!-U%%NVX>{R zF9gIS+lxtdV8?Y85VmA9)pzHVPfw1^CZCs_a#Ik&g)P?101cXxf`}S?^(Jb-d&iK2 z0(fsml`g<~&j$H{$!6hs8TC09u=-&T#Qx}lIN9N26~$HKzWu?yVHOrRP@ z9!R_suI^;)oB*z5H?Wf_1RPCcF;LGiTI@{#sDf!i6x^7S1zmy=?ze8Kfw8)JU{Fv7 z9FhV0TRPPBKHhx_w9_Y`_$XyP1rf}raceN9dHhzSZQAGbCENqToQyStQLz9HccJFz zGrr*Brd*(bkl|&ZpaK#ix$#e$tU%zuN=^N~*|5_!83aqAv|==|=?gyBE zn5{tTbzz$UZ_dWk6Cq%n=K#Zd2Rk#Pg$BeNfsm$Z-7NCvLEU$zU{n?^f$GWPo)27J zt1Vg;#3$BEdw`{nfN+5oiw5ra6>92aOxXkBBeOE8z$*a4#|#b*S|8Gicz)G#3k?ff z0&o~45Bw9ZFuCC~lb|tZxwcK{lH<>ov&H)Li561=ViS{W@bm)%n_)ux`ubX@T@E0j z%?4UHPFq{sEEb!F;bs&NNPtlTpyZcV*m12WBbJ_?hI&|K@p{AYA00mH@4j(y+RY z(6mhxoR}LL8bA{f3*f{Y1|toNSVm4R6P7e$cIoc!ZdjrHsu#x;w1hC&h|v~2S-8Cc zMX}>(#&s}AErp8(X_;?W7zyvQue0EEi^ra<7kcXYRJS|HVI^ zegbMRYf<~dQ0gvV+FFj6G6ILNZugr`zYFqYeNIn6s~7-2=TNCYq$@x=&wx?|GN~az z;vi})1nz`~lYFRCqyfXd=*7N^ok@d_j7m)f!!H+{<%9 z_}#gv^62WbP=8tKqoxDzP64My#LyCv33~onThT?sn_FPCGKU6175R@xyMBF82C5EZ zr63}T8q_qpnoPib8 z1yu%I5bBwL^5i`gZxD5HHaLp;^X@R3E{4-Vr%?VbbY zRtDHFA*?w;JVt~XO~B#@DNvqMBShvAF#HwzTLXmq7O)##J*tH;22m?5Hy2v8_9dx9 z(C_h+%#w|92kTM+&m9yF0I4AD2#2{YfM!G4=f>D1Pk2)(Lm!RGB@r;w+ygnt~V#!~1eT zMa3(o1OBJf;N=0na=#|OjE%*?AkF>!*$jS@PvD8fLjl0%N4a>;2q!qHBcW;a#e;Ws zpc#vSnlY5PBC|$q_co@=T!L*UD(``=<`H1NV5_U|xxxrKlV+g7gIbUQ!bJHtH;RD) zQPA113^Rf{P+#J_)RluiUuN0|fXBzO}VNAbZS$TL-<`-1_r^`o}k{02l)!fh3=qGoz+L4J^riMP0;u_tbOMM&>9ESYhe{*4{V=L5&*NN9TDO%9eb!ze^Nonr&qnwlQyzgXu5^*(44&tU7eL$^OTOxJmU z5F{Fsq%Ck!#{J~6Z7%4%JUiJ4Whqw8Prf;+o<(<; zFfqGZO)sO81oEJn01~6^>NX$797eU6`>Uqw|F$s#~^;@9lkY?uxq zU6b{CD7An}`VKLT@MG9`^E8SQxr;(?3aNZHZa-7u?D5l|9HpFr5Ax*JGA9hrc^k!y_Zi5EEdd$ON@DY^`5B6h?9sEkH3BX>`kgi;D}yZmf`z0Rcj& zXK2?);OwXq7epN7aW{G&gF9nSsV$_og}T*|kmZ?IkHH9R_kZq&y%nAU5z(rDwYR4O-83+%kgAx3u2Iqrh{xwt6K^L zuVo=w!^L_+u%jn1_JO&*1)XDmvVhm0aSJWcZAnQ>#F!gusJ$Tho3kED zjEtqw*qqfB#DLH{k5MT2HuB8mMi=JcnWQMIR&T=ydu93J0TC?nbPY|7*oz_-*P~`@lkQ(n{ zGArz%w-_1Q4?N+w<1xbiq$Jn>dxqagLbhT!xRyc2h*N1h)&0Dr{tLLoVkQ^=nvz2Z z9w^0v*{l2R=u_NWq6+#3BpEbqw^fWv;r=xub2}wyvtB4@21jEWZS{jl0m%#nHlLm`zBz=cGCnwE25l*SYFvsBh@BsE)`}@@X zqF-R?Bm5{(yGiGa;LgH^2}wDUerYv&*E>y+8+b*)Fc|SrLFb4M@KECW;Pkt#xMbLh zj1U}_-~fg*2EuRP0j^nD1l?|4U$J} z|IltADn@WX|3805B3zy%xP1l!kDBr^m6!!7kG;3kk+ zf(X8=`UzOt16-8mgyglDeNPZu%A1;gbgBiU1lmntsQDkA-~6pys8NtO69R265Qt#6;Bl6ie9jbg zAw|J$EbKN$@39BDM0YJIL`-*k_?IN(%vm#tK1dp3Ue*&44fac8Xn#lYc zTmQKzn&d6EHox2ls>TLxpw=$bxC=>aM|-<;aYmH|zZ0w!=z4)&`J7dEkr^xT?+1}6 zc#zMDJL-rhtoE9716K*ADhz?{6Nio!XF**(JqVs{qocbGsljl+7`^lV`PI2*_*eKm u;kEw_ya!Sp}K-v1r++xH1~(bKH^PWKZB{>VrxNEJ&MzWyJQ4YG0o literal 101607 zcmeFY^;cX^@HU7BcL~AWU4z@;E`t*!xVsOQK+xd9-3NEK;O=h0-JL;}&v*AdyT9yv z_7B*5&UBx9yU)2jRb5?GPdztWRapiNnGhKY3JOh57N8CV1y=$E1*3!r^YJ8Yf5rQw zLvoVUb%laL@B8luoy3Gr1O-J7B?l1K^vXEx@Jun(c7J#`w9R&fXu1zex%4)}!G3xO zR^l-Bkv-gfxQ3NLp`pEvO~FUpMf=$oh$Bf~L_oy8^vQG7rD-peM&BjQ&fWUC;vl1A z0Wv7^Fd?heV9Cwpc`x%Y&DFZMR^hvN+R^396R|@;8Z)?a6##xdKaya$qBEz~ZhdA?uHCR+MkeKc#{f25@#a}y@3jrI+C6`G8DH&5)S**y^N zA{1xhMVkhfmW;^*%gHQZ@r!L1oBe+*BV3)QWRbyq;MAYlMM67~|1}eJ++Yx$I| z#s0>|1_*>PSzpxEWdztCPv<+=zQ^a`y29W4VnjQU_b=L}XuFtU^6|f>`DQH2#xZ2G z(SB8PUVnb9psOo8Y~Z1KM1M|kK>uTjsfhpcrs)6sOQ^DN42ag^EDRajS>qX|Fr8Uw*MNz$?lSS`NyQuJ@wLCMVUQo1o=Ys z76a0%GVjQ1ugH&CJGLKU+51l*?f)Z{W$pYGrY-HV`Rk__E|U0SlA(i}20i=s%6*oR zo&Q7-RVCF!lr*TKk##lj(KuTgMcY*UG>WJ0l%7JRL+y2D#ytIhpE|xxBpwk z|Nny@ra_l-3au!De0Y!iRoyPqTEP&)g2r1u&BS(5W`a})zc@N1~Y1DTN?sDeGg|a#wmzlfU5V$ zPyZ?FBOySpB7^JXMT$~)+<2TvetqpoI}`5*C;*pf3Ag$^fta*}+D&$fzAC544gUK^ zNhuev$(W>+&Rb>h6gGq=Zj8^I5^y^`3F<+~^6cCC#SeeDmwj-g6GEXUY}R#;_=%@J zIuxb}ZBP4+|9Q+czPAmgjD5q*LMTr)WNZI#qB2#fc}bBizOTlAtB?G1{394m^HptO zgIzKtTM%LfgY^lg)%WMcmjxa?BxK!&YG-C=MJpZdwVOe(s0?hq6c-4QG%U8+VIK zAR0ge+G-ib`UV-dEAM7+{u0KiUffxmNK`EWZ7YM`A(iN|%UsYv>mx)u$t}jbwA3l% ze9+XwVI1S{>-5M34R!qe!edeq*FSFr0B85xX*RaSR`o>Om@1B1J`@LYO{={{n9lfA zD>E&oWB@IlJI4mk_Beztq-upQdrHaD8nv@f20&Q`ChF$(U=E;Ws^FZxv@BO2L_U+S&31zRRgD?5do$c{5 z*tyLVbw=NYvmaI*i?$cO_Cj4=O1GYIfG!!9r>c70_s9`Ar{J*Ncb$yc zgfEPJuCN9CHV)MfRG5WHCvl~rVTd4)Jk5J2L7MS^o2G!mg{GPI$NWC&{&)JPHBWO4D|$T`Cr8j>!CmvIhyPD%&P)l#U(Cl)m$!wU-6&F! zbqosuwvlLYh4pZ?ZDq&S^=K7+a+x!6i*MHX+A74h(Z62n*DsmM*5l(sh2)NN_k+&U zLN*hNs#gon@4(GVg89V=>Cc#h(I#GJe3CUVy>Q0MjL+*Jof=Zw1iUgEDsnab;{9zN z0}L&@VUc#U&zo8S8{#j|2cwY~6i|5^cL!HqMbwPs_`sF}-(@_}vVQDdOm=MR#r$L~ z1k&Q!&hA2QRg~)$$AdBQtpj|nPv@iq7tdap;`v%gCF10(YuOhBp-pusr}S{?mIYc# zvIPbVOoll4=U;O9#{TRGsLR9{Nv~qibuUz`UM6I}_{|^7Bv3o2dQIwFp9QudV(Ho< zApBzOeAM>b@P269cv^tP5bCz=N*z82lBH=xFZy%gGufnMErCC?79#LjXyU3TNw4_? z{}vvdgL9?~1dCMX-gbWyc6NTrnl%&WYhhRW@cbVK2mF@`w8Ppe+@=i?VnL%Q_ZCpE zn(A7s(Ay5eDML0N$~Sge*r_@au02VtK%}^T#p#c~^y~C(nP@m?6<{TMe-i60mim3(a55eaVH zW;Hzn&&OoG7gn)#JZV{wZt`6#g6>P6iCNBMoWFmKW2@Q)N`_OM+Em`uqvf$HE>=$6 z_?wfi#m<*R6@UYS4Z@R2oRSRZlJb9#5(3Y4(x3X+##zex*vpOTg#z)CN(J1*Oh=gvFDb;0%f?MJm0|kn}9H?@O*QVtJ325cgLViyywSM z-X5Aetm(-B|KaS)PF6YW&;I<=4SwZ_unl@=d6E9L=cVqBzNorsf&n}dlCwY7g)=Z)Wcrn;oz3ZyJMRa_HnoTjNG;np9YgceLecca zO>0(rFJDnZKCvG6o7XGfdkNu0z`$*r-H|y#x+uZPSlHoVu_Zs@Xu12q1hkRe(sF~> z0af=E`w5fZ6PAI+2zYIo>zL!17$y)DQMxA5vhBZ0Jz?e*+8Hk@wt5$_b20_swXriC z_|(9ZQg4?WQy#gLe{W&uFUS?@q#gy=fX>H&#c2pWHNu|X*g^du5)0N-70R3M(=}v9 zI04ba8K3?4#mPn(6tLyKl?a0HN%XSmf@8qzZ1Mgy;p0D_@#SNNm>9Sezdm)qN0+H$ zooFQ8kLTQA&$(Mh8JqQvM$Nj~L|#@yt|+`8UD#$xY|omyTzh^5 ze4|)sJoY%}!*z1}&v|aXO~8}AwqZPexWfG}B_h8*j1>-xeP?0*dPnfu%$5;6xg9u7 z!a-3P@DOUORwe9%^<;R5+6OxN{Ef)yRaFhxr`(aLki?5>nOq_N_WX0eC^O?qYbC3M zhF5QyQel$qj*ZL#vm3645(ZBANl|5jhSk#NIs<9jexAC<4iVgomazFO=F$&2E6wIY z$Vz;6P1DMglWcs^O=uP5qbtEbNZ*j8y9;Q`wzAwhxPB(B+D1tKt4BqVE(9vuy;td* znEjk4p~chy+xr+s$ufNMQhvlaRNV@S#{-P~K;oSvQkgr%n!b}WMqX}Kw^S<7MfCke z#Q{wZj1^osUyZM0mESn!^OBs|Ii5$>f`Y z%kO7M#r7fDFsOFXSFdPA`1(tL#;VFAnBBxMAUv_hZls)_sm%3X`2FsT-`30Q$XpUu z)5dD}Y{%;GS$3BslGB+Se1Fxs)lH0xp?Q%dh?5u~uW0-2KMDj@$!_@1sUIivR|*y9N%sE+J{NvE&aUzfG4*ajC^-mE8i!Tkn-4hk0T>QyDFaFvyYnQxKL;lPkfHf!j-_3V0kE1l|od}SiA;gS_-1q7DD0DsG&4NlnR_vuRD+n8#i*BiZ3;Q@crE( zCsu$jYK9t?=k#N!Ky{veQ}OLO!R=Y!db1hTsfAJo`^|B!K{r>+zdgGAUaS+TQhewu zCp&hEK?@^-Xmx6sgwa7cw?B#r$wUc4S69kc(6Kl|lQ;ZE8y45;DZlHTDbYF*EE;(3 zh$E1Atyt6fb{PqBu>Oybh$`I!vt-(joso))pE%5HULNjF#*! z2Ftrz!Cj7(z)0j4&BCwQN|V?n(yohA(V3m@ZOqRBZBukJB7gaZmEE zE4a@?awaIT^uy{lE@8CeWUPk4 zJdpH0VQ5g4P3XKY4Mu`Bf1R(Wcv9^#X(@tw(g9n?=vBvUxwoEXE5Kr)$-vRVHfSYb z-R}2RGS}-GkW|}X?T1B6^90A&LC-5%PK45K=*{*8hXG{>X_L$2MLRdMZmsJcnVZDv zavCa;b0`Jvu27BXa*oIZ~N4~<0OA}W4x$(euo6m4_i`CpU(-2UM? zH4u97Yaab`Pl)LAAe-L^N*Ay3+W&~B(X2W`kW_<63TK*0D~bEJqmRypGqz=~r3Sln zsuw+>ZtGDNS_6xr$x$Ewr2bk>$(ba$h~|4ICZz>_$Z+U&+D`G5M~x$Zt%b$;twXDT zto=BBp*a&Tb+=Uihklo7-dk>p97~QV-WQy)2}_sU2(asUvtCq)@dn)u)X2Q=-_h^Dvi4YeV%G!+A~;J^{9cFi`&yrL<`YcKJi;C?nf zuVSdI(bSYpGU3i=j8H~hVsm|?+)RcSP?l22FrigU8E%t3Br>1E)O z>Leq(sY#NEDADtb>*mK{+p3POa2}OoTn!NV;x=eJtzpqHU{+yLWER0s*_KxJj=%lh zR`gHRynOtR1v8l7wek}2IqgxAT85`c*rU`;O~%BqKaToONeG=y0EHhT->DMem8NY?WQaj9M4(Cd{oUfY1dH%au|>T2io{W)`^psf8sLXfZN2Oh+-Q z3QI{|)3;|6-vJSHA4w)!P)Y-+ViuIXt_Yhpuk(f}_rI3LjHH%x6AH5I$cgKW!I9~i z#jySbUw<9PRV43b{C&{}!I!5cisew5Y*vlsK87{9UlD0Sx>2+aD>0}v@LArY^gsrS z{JMb{`W!iq?$tg#DjCsgeq@#_G1~)9lplFiStzz-wZ>e%uhAL3CPBTfG>bXiL!1?ugbueHm%Ti|ax1K^cB*+_i z-Gkn2U%TTdS`Sk}bKd-2)9cb#tvsBt1gJ*e%!D(+qS{^UfPjI;B>qS6z=;+k$t_FW zoVUo9_nHO>Z2(S+r#u}_TjpmcN5+Fcu5qb16h;rOb$uO&T9JQjkm5n-rQDTdIVL9>#6{ec5&-RQ z?(Q8MUOou}r9W`|?|j_$lC<02t`meQCq?SWH2*tjJhsE_RRx2LGShDs?BKJekxIp& zUi{C{<`r?N6H6Kx@&A$|`acdi4!Bp7##pve;G3QPBkVYKyis|xZa&wdr@##J@?bq3 z#~K2Drw{|iGPxYsp~G~ylCD*bpg_SW)*$qgLPNQC3hBW0+p$-LMFll`_%n|0z(_pn z6%MPItc%tsojYwV@qp6IIISu}zQ+V3{*yRItBcQ9NNGN#Z3+?S)R;W_{X@Mc75hR} zYmQcp)>TsUU#7nXp7yM{m)CCVi9b8wMutKS_YpWtW>-I>1_wn+cV$m9;#O&Pl?EoqKzn6i^(1Nxuhg-X}r*Zmc9?3 zrX(x^GwmwXMya!pq%H(a^Iz~YK#>;l;BzT~jccZBG9F$D40G%)%vmRTW6Y%nJS^3p za{@8-($T}$BhMi*Rc2BW?r$?FssA$YB{BP3T%FhnGuYVAmhe>r3s!U%=z!Mw7dahH z@{UL2O#n6S>_6>T8beiOf0E~#ScXv+H8mN8I9-ImFf{OU$I1KODYl`M$i~F_vkV z`{GZX2{Xk-;_=-y94zsl;he75MRZK2KzKMc6qjhm5HYh_RclE1)_tnSy2ySdMRc7m zU@!A$AwfdYV+9J*)azvjc2HmwOW)$6JOFjwd3lby(mnB&8aDCN{_A@q#6+Lw5Y~DfV zf&vjaj5!Q0poMMh7;BSbYg?bP>1I-Pt5QTA|7&2&#y~ow%y(=NdTrAJtEx% zXk)zhfrG#%bL&g>?qP_bjLKAr@ML^nFc+;dvau;nB9Y{WHK`D73Ea*9a$eQGN&oX3 zz-Vl>y-#pHoOVvkpqbcQdd{gkJ1vR()g1w|51GS#Y}*bx5bn#Dqv8T_VklKSN?wvW zJ9mIhucBgKo`hHt9hs%#nzlI;I~+V$srrJ|%)5@N9%9XzAyo>*Zmmj%`+nAd{hrz%m~S^4Mg|3md!Y9P16nXVIiY1R`>aTt z)MUZ7rqL!<^Hb#0NZQt?wr-fkyy;u~V0A=jgOW%v*eVxyS8#o$1fWPAUoxyJ!-k+6 z2H#CPSJ|TeMJ!*@tYmp^8dsnnPVbA;XHEF>(0 z+S%;PC{6rTL1MZS{1wl-Tl2iLE#Vhwn8>6 zk!sX{VQEuSFY4_L5qbsWn7S0DKn2f$4&F<8l6MZ?pa>d=UZbFUEPp7{%D@{6y11yj zJ1i{jAxjlT-iS}NWP%25`~K@eojI_cbFPi*z+~j?1#C)ng_akvji36#?T28IvAnJN zdvWIabCa5RI$0JC>klNEw4NROH8MKNw~my*qG|MkftXpj1a-*pgj%VZ?t^?E0N-=>2`1*ZJ*u}Xp zj@k^7Qq=+&8lX8zQbXfbAQiqIy@dhR;)DL-1HLN$S~n5nTzO&5Nq)wTME7wLsJ{Yspa-UWV1q0TfpMtqgQCn))>&l|il= z?LlEpCk-i}sTqXX`+oL2{*5wR|;VuwWZvwmFZ>WF|zxGcZAf-hZG6NJgbe)7; z`S89y`{eIi=Q(muQ;NTU!^0X{u?mSfJw&;1#J+sXJkA-!h}HAxO8;!(D-?^Z$d#&W zUkGBCPIV>if*$vS96HR`bVJ;}$l6st%_X+xqwAcgF^hFVvpDX7-%6Sc@x=usF}Ovd zF`{pPc*KE*l+Ir7r}73$?9i>R03xnA@J0Y8MX4%ho{B`7%G^XVEy8LEDTB$>4 z1NT;Gd1+j@k(@Q^KGzKjaYqfCZaa@OFDGq;$m)wvHZQ7yNLuu=#~(HYrA5Iz?H+Kt z)b1Zku+TWc;TZ^pS4uQ1$f^1IYQEg`PKIal9oL#qllC6$$S7IA^O zWO{L`r%$C&<2T)c(D0a`pOGN{>c*_(cfqdWbkcqKLYI48OaIe+GVtQbN6lsw<@}A} zqbkB;1!S10>iR#gdnn;5kkBREXYADKlR4cITRcm0m?@VI{%9c z0Ovg1lXm~;#Vjv&;egPwXsSc196wBrmz`2-TJHyjU#4h7@2?*1!?|+ZUZ2tO1URSo1>*p-ho3b^9_Me=a{H6nkcRag=1vTYsrFqK0bzHTXTI+oc$y$f(p_{{ z>kLcgqzaKzY@n%B8GN-(xL{$CPKsD+jxEf>W;-tI{d~!RkSe)PNmQiLxb6Bb$ExQ;)zr~mR(t+U#A}PM!Z>(fK{fBg7NWO|U1?k|1a5BhZCwS=ZjvvL zCif0lZT3~3r29UoA9hv~9INZF|L%ombAXrzA-~<;D7D85lm3vJh3WKr!%NAP*8Qq` zsu$(0!*RtNU2abillc?}=#!Sxg%h{nHpBx&)R(27tSMW!W}DRBWXaqZRU3&W59fe> zZmLo%D>mSZc%m2Dxx`ima>$X2CVNhQ>6Wz%Z$8ovRmg)1^ZuF|73b|SD(ejZmX23<`akAY2_2ZP3$LqGf{rn?4S3ku6GzRV6GxMNV2B)MXt%rd1V6pP{#Z2h?3Ki* zU`~>cmZn1N!t-=u9uGVYtICJM)^W6Lq zA|Vp}gsWy;V34(@GI`^M_}HJ;EP7aA(*@E3t8j(3NmdT%Dby3F4z+dQ=2B5hYRb9k z7eHUMEH};iYnkNP7&RWm)5*N;PmOUAmRp))NiqEL%#?U{cP+tQzQXZWghStO^@&F! zhdH-~7z*cm5p^BOSd!d5=X>9K)Eo%UCLT?e#wdCXC^Z+{ipLu5}7M7V6- zs1tOnr{TonwifH(yJzUb6faGUp8DYQXr{)0AVZpAC3VSMy3MXn7A!K9>SFzA7%V;1`56VDjaJ^nfws$gma4#w-q#Tm|18OC*q-z*PqR!wSmpL%&3wMk|_x zZ>L6ZRJ^7YCIntTF!o~m)#p5Vk-NDB(W|SNm#0Y#;p?H}AD_u??*g7?FamC8joz+j zLQoC@uE<2whkrJ$d&pphUU&`aPl!Oqs{HndBqe*H!Vc@Q`5oV0S{_6%52PaT-)=-f z=Zjr$<1qm)&aBBOu&!@UM)XM$d~4P9b!9hXB2P7&ujd%=kex`^76X~moid|WQ(P%N zwl?>THE*Mr!O53H8BxgDg})JF5&#gMQ?f{u@cQKKVP-2?wM?%ilTo$%<$aaky$tNw4Wh`C<|z1zI&Y3?h`jL+_3t*_=#N{_kdVo zl(clMtHp}YyzHf3x)BmZQD}EH_hXcf6h9)Ep9k^$JW26Seki+1%moFqgi^uOJerp- zdk1-lvxuIp&*jS!lla$@Z*I31*Qectl2xS1QG161aPm}T!gUQk(&AeE< z$UZQNeV<_Jze76l&!}FQ<6Ku8x8*X}lEt9QQco?!6^GyyIiO>wkLxQfUT8W z`tUR~$$#&(xE}c3UWhz)T)a+Jv9^?}(!V?oehxU%H-h{_*znz;6ZIUTh!nlUAbX#V z@qgaQei2F^A8__t(6xk;wYz@Eew59hew_LxN;+5*_I6v`^?2^tb=QN@aiNj@EK#AQ zU%UIS?c&?peAO!xmW-O){j#P1o9lbEX@BqHrqgV(=xg}I#P5gBi{5DIxiG>e=C(7U z&ByCZ&vn;H;Rn!l8CjVoBiY-KH$by&{=DPjO$Bs}bFiB2bU{6>8KG@SMN z8^Sy33%tC_UTj(kma_>rb#z-+W-BD86)$pAm3gINLutU9K8h&3o8UwsP61tqr%61_ z9fzX+nlf83_~s>cf}CX?WHJuV+h|1%9pY_4#XH?>p#!B2#7fhIF5ifMpK-h$%Rg;X zDV~6Vl#5Poe6Uy67{7J&{$PDVXOyR&fId6_1^ylC`5T{)97snFUGOezly`38xwXHq zzeP$H6`?d1RpBN08Z2V3Hf`x#BC^44@20Jo8swApiA%ezK|@8@+pNHEcE()c5A(NX zCx1uB$%}x1J&r-r_33HnfiJ}ls|yb&3IT$i=cniAmHe(2o6duV3dtKTbFMK$&xiS4 z4?iNI#?p9fCo-0Md#OHud(2Nz5ir?kZvk)%30==d226ikv$MjE8*4<4?cRGMG7|zP zw|*i4&(_7?JeDt_I1q@d4`XQ*v_#sE!|4_>{AQ304KoMH-d~IiuhW13p2?F!!O*RJ zJa4-=yCD1eRPcGx#(lFM#OQj!TP7f4PqEHX3R%XKkPt)k1i8jov;f~MH&xTuYQ_ii zRn)@B^VZ@0t{PU7B6)@oCg*mF#k%EFXWC77BCCr1XgmBP8px5Ag_+t`(PpfknDSs; zon}fChtJh$e2Neq(7dlqdOSBWUm%-iU2nhtQ z8av&9ZyKL>-gp++Psf&oeY0qgldFm=N09$fmd_w)kkx^2=CK%L4aM>68zI~Rf`#Il zrSEk1)5)E1M|+uY#2~rnrlRCxHTR{B`j|nssX21J(C*Vt?PwNe_LkqP04!ZOls4|s z$z}slN;6P)dFd}C7@~e;9zdXnrzc)yNWklWb}%_q4xb!E;-xV=ainH{Z zjC)=xD_OrkDt)+3+iy2Eo)cM1X7?G(V;1+Px`5lP_ZwE*3PF|X1^nwL1Cd2$lQPv2 zvvyaDk($mT#vKj+eBEulJRjA#?8WcM&}S}fd%?GewoIJFwnLEpTO4?rX@RcP^uNv2 zW&N&?OpdWt261+kiHe$P>#J#yxziN{sO9bW^TV5i;|%mgZ11s~DCk(w{7EQl7qy_! zlI)K{kAjv~wNLa2scR>jFo7?rbM=&{Q#epkX&A&c-5EaAP^el??<)f=F^AWKXz)_{ zWENU#lwx^3 zoY03KDFrkaL#o`3vXoa-(gbF`&TPx0wUF6rZFYWzOwjnA&g4+c=9@!WAjiEmpCBn= zR0|997du7Vtjms9#BYmT@$c;-tso_}gp@+)DbhWo)*ViMi<@{!q6<+C6kT}@h=&*) zN#=6Q3v9riE7Dvm_L0g18z+uhoNB_1(*y#nzv5%89Qd8P2#_OO)Eo zRh#vcniEzASRu$*JaD&NO>XE~;ES)qmrwp#!sN)?b9BX}302k}rM?q1&!xe$t*k7` z8(DHYrip!fcP(ok?&u=U8&WhhFbq7F;!tN@UmUYrnwbiUKHKA}Ey{=eim#8@e;e#L zQiZXVD2bq_78dffQIQf@<&SY|RcFKtLmUIAjqW+LolWr9S(3R;L3^&dPg2E{w_bE2 zjO@v4dltj+ZFt>4N8)$ho+jI^)PJ0uwdSJ>At;|Soc>-hX_fogtUr5poqSNOK~7F3 z0mkHV@&Daoym|M35FYJJeSLku-K1$dEZ5qb6|{fgwfVmVuHh#GHfqhAl18aUFFM}G zxK#ApJkCA{XZ0Trj!9dD(^jjB!=g7->4P2WvtnH=bUg$X6|LXS{iRj@?R6=G-!+e3 zzL?*FlGQzt7IJfQ`qPl_AjZt%LK8!aU)zlo`ttEZ}et?a9b0X$xsJIhq49aJZ>)bD~jyT)*%et@wskgO$5m#ZX_Cf!JkS2 zM?TR=}E3 zx!&sswC4Klz16jr^Tt~WwGOYjj8f+_N;01 zuENnD@^T~kzW)BW;J&yyG{zRNtlMvTbz}6XjtQ4(NCFG{gD%_cZ_OYv=wW^IfGy?W zc`?&>{f>{dZLe{;&NAQ~@)`7MG|_d5u-S19E=%ak@;H|JaKLY;2XEF+PKTnMkd7+Q zT{c_uzs;(S`%knYkW;R~^%`hRq^lr>-Svy{Oee55Xf`=B zOWk>;Sp{0a4?5tA?dq2=p=b*~j=s5dSUjE-=u(BLRiR}>Um3}-;`1b{TA&P5+aY>! z(hBpb+{{Z4kN;8yoLy^adgbw-O`lWOnF-9J8-sW<#7brs+R~d<|ALRaF@qUKF0WpV z)S#Ia3bWfQPx*t{V;}zZiUi&LJ*gKrfs7%8w*O;q_J-5Wj329lVzS|VTZ3)3-VyOqWERs+_61JO>; z58~wQ{k_orV_HnW_N=4N&dE3LiP7>T$hq%j?dD6RUd4x({n#L7^T)|X{{0`U&_?Z! zW4t?`SIB19;mV1_kQwKg5I48qN5BjDFWW5p_T++D@nND50Z!8&OjG&7?NBr#(W{4S z|C{v9%az4Wy>ie8DE{Aia^Jk1I;UXRD{A0*}ynX*{>l zJJ+tR`L5a8Ap0o&hjJ?s$phj@*WdjKuk9|9JJaK>CSr_e5f!1(?q=?4Y-A!azYWA9 zeKMq`HbM_LB&omNe|_DD_jx#*Hf$QOC6(!rUd|x)V(+^A`LDm&iFOCLd0VJHxKA+- zM@YzM#*i&Fb!u4zu4L>wS@%3gd7ARRcGS-4(%UVdl&Ab>bvWZIu_=gtBk113s%%`=!10fhQb&8E30WiM&5&YfS4n?z%EmZa%Z zszFtFD;>l8R2|VrxO&QYsP}*y+5eFp_(RS-Lrh|+k+51QSt;r9BR?{H&x1@)1QV-- zpJ9>x!C|{MEgv5NI0hU9v!iPA$l2BCAoj8&2Cq7HX#hA=$2{L}AmYXb2%Y=zH0k4W zHg^}G_dIgMFw8+Yk?cu}x`h=3R=%%aZ_gatY(E(JrZt#wL8C&NKrG$%$A5B=e?mA> zkUx>oG{IIXPx^T_czN=DX}onL2@)698f#jsPx|P`{o);7{T%g?gqFJ_-2@xS1 z@H?9Ensoxdmg7m^AuOg#?hkLb2!;1(WtA#0UZtu1{eE*g)DZ0k`jRoAQT&(@=z2FU zoxX6Gq5$R$lC5W;0s@gx@hro@mD_ZMsU`fTPX?xpc&hpec=IQn9gUPWky1kKK1WIb{<5E*Peg&jed*K3TSq@)RpGN>YRZ9>8jRA2u;yREkE zoitq<3aC?4w6sPm9Mv1;NjIwKM9QDg{17!M(}pg^S96A4D~HefZczh4gtSa4i{tON z7SDr5H0i%?66fIHt*Z8h#8}pulh$kjr`HbfI;lH8czvr!Zf{0p@E*iIWTCOpC*gJo~jt{W`EqU+W5Z^y~4fU6p8gm z{d}Q(fxGDRq2#%~gOdJtqnYx*7cdwzna$jhuXX|Agb>w}kT&n3SNka4W~;5W7T5bh z%iUDeloFG>r;h!KNJ*DpiEaGK+L*b5KL9*}MFDXi)K`R}OlCj94p%Z&Xbxwnz<7V| z1#xgrhQ()!46b|r$!%ewgi$L!WUMbRST<-NQ?BPw=P6=FUIiJVP`u1_x$FQc(7tIY zISNtu-=*W$xO1aTE&r3!T(;|@W#|yAI@)|D*^TlJB>J-EQxSDMbsBXn8cH48UyZVu zMYgLtjxIi6Ik3bXW1aKCH5icl_jSvoc)7z+xF3*O+uESc|xhD>s@fSY+>` zUa?7%?s{Mq_i*xgR1A1nDIRW5l_@d%iK_CnGuLP5@~>ib{Sg8enfsh8pTim_??2a5 zbl+d=_m!k8N3+sI)2Hk357()HKD*xn$FiHl9nsDx^KBTzLREuvIO|e;4Y+(?7AmOo z;CpHBv?2V1H+GnqWq_iK#kB_}l5fgt*+>*f+^6D41gUqlsdrj%G#@21k_xNlKJe&? zLL_q$@UG{li=nniVdTN}>BKE?;-aZKS@f9^lC+79gQBU)K=^dF{u2M)&7bcnP-Rkw zz;5={>*jQZ|KQ)YD162}qq5*@5ELd(KTou6TMH*NU`?^YMQN7Wouuf5rtpk7$7)}^ zfX2)M>Wqu3%=DWy#+ZEUeE1)+Bl36 z`vZMy+gal8c!n=4^*elpd;u)o-1W?L+hb1azDj$93Af?u@dRr7X5E^O=zRXn2F{Gn zl~5dW>8oyk?K3%pCXRC^V9a5##Z5KD4@uoOx(4}1LxZOKr`fT~*uM6)-^2gm%-ppJ zkf+pqd>Y5{OANB~tp^5cAhF7Yx{cYRPFYB)&;%}BW;y({keaM_{=nAZuotEO7UH$q zYB0Ifud_6Og#2L${`Hai+r$FR5xTO1<846Jrpiat-l?yeYYBl7{qC!C^0#iH`QTt@ z2|hk1Or@7VA;^%)FA`42jSLq3#&gv@a~lc3n2_grh9tVK(DL^yMe+(&6PN>n>C!zRl4NA@X}H!Z3Q4H%1kUDUX0Bi8Mw@AVFYg!I5;NAf}H_Vx4g8>YyU2vMCp z6m@>cU789EGp@7UlDfsJWnPZLLD%Y_pRxvOK2^+bA(q43XxLV7St!m7Y5!1Y;TE6^ z#Y3Xr)4@>a?ZoUjUlr<5t4)o(_*H~2V*uoOij3b|53`M2@!(LhPE3OExKtH}DJZ30 zZJu4Sj}_jDO=)QUTvO~AFUrGGTYj;-zKE`rvAEDi%8b@7eMROG8Z*c2cK2IrO`4qa zcKrl@VG#`!cz*E544!_z_M{r!7%d_^>ejHpAM|u;u<-aRA~uo$r{$k*p-54b%(Smt zZqG->FK-yyT4il0^w|Ptbz4Wp*~r}okK@D!0qX0Kr$0;m-L_JXF}IaomJp<5nZuX^ z4N1bV03ihy!F2B;M*_Mxzj}Ssidn#fpWLR1j~P-2`D}5^w%k^bP%Do9J<&pumaUa-44U$x=cyJk_oMRzI<1 z9?EpmZn4>LxEfq5k&**IHm^qEwDDZ&LDp+GLo(>jN|0 z#KOGM*hH*-9S(B_J1)YTmg;V#{4mH|%yNu*8GZ|^zl!1;pA%V}MI`YSvc%GI6cZ-2 zpG~3mvPmaxG_I!>#o2I@*x!5`QpM}GQlFpjC9TT#u6ORB5hnFS-3`_jn=4n%`mYK@rWUV~ z^3_FUP53#=h&NBySQB46BrW%~wpKxm4VrW}*jjZm64=-*FHTI{S~fNwdFhTeN5TWvKC|Ge2*h|s}^$objoD(R9l1gDr6rHi3pd?b)R;{53R}-X88q%)-aM3 z^-!?~0XexSGWxkD*lAwd_mV512%*|GLakfx)R}m;2k>Te@|XL+L6kllz(LP?7$rma zKhJETSnmlmXr<;N_Y#yC7li&6f+C=W8YHvv;iLjNTVdj0;W!wXPYU^L!&C^(qH4<~ z82k{fcbO~Qo0%;lr7p6_X`cD!NkypfkHh{c89yutW+bq=K%!VEZXq@m>3-RP2$1~r zRI9s!$M-dTeb^&Rwj!GFDuif!oysgslVYbS)l=g9H$`hpn!*TP)N*j!?`ya{JC8>D zb~F6?*UrF3NuWaEO!~&7K+}BI>DDQ6^7_L7UuUHzqmq*K@fVpCdi-br3N#)XM=bqm zrDJ$rhLw?|fMi;BzRsMAU=~P{1qyY;j`PRD^@e?W+PYg3KT+>eb<|RhqL8YwB3;y7{PXNh?Imf^GPRm4Y`8E3K0@; zTI3M7Reym0eEOdfTW7_I2AOddPX0*e8kfpPi`l2em!SKz1@$CA9(bYE0s{b7tr5~X z*gMOcS0Uv8DUIjpe{liOfx85bx`8OX@ELu1t4he*jBOEs=*K*zWu-WfdzCq*wL8)W z;|l*{CpCLT)~FfIIK(rcTF#gMY+@CY#SD1n-!rkm;y)?p`m^o>!HKC#4F-M-3rk66 z7CAhvo46Fu;(}8%xYfDUA|iB=yKGCBNpX^mlb}^=XkB%2*_%PMFi_$ zq9Er529v-S{Gc>sn2FnfmBqq@nL*`AH%rLmKX`MFr=X>vtrn|&>WG=_NY7kOmAl^= z^DDL~=np4CcfP}CA{>J`dtYJc{snXD)*DBR1(s;q4&e$f|09Spzd&DFDUw0`@hM}7 zZH?TYLjpD4C};m-c>|MWqQ_E9D$2I`!!a7jQQ?c1Xcxbb+3@%Ks6+)!oG6qYT*?U& zzMsSK?F$g*(^w9i5aSj6ovh z)sIjQN5L0o&@8LvrvaqUC_5{E^9vKe^taxMxW{#U$~Xve-v(QT-PbmX3L>a?|a2H=e(}D=32X+U%oz#*`~AF z4=*RdJiQB*fs2|VO0Cl=FxF^K$eX&{dZMj_23_(XeGe5|`<%Lh{b_&_K}S2yT!6UD zM;hPbQ%M)a?n^$m`&{t~v8^P^cgye{w(MMZEGs zOv|pmnRjyh-%{DIAJkH^24QT0QngdEN{bv6f0t9!Svz^w0#R|dW?M#J`-jv=;<$^R zvS_w^$rA9w2!7R&M#W_r#(Gt9$a}lQ_ z_fiJv8rGvRQ`PWDsG2|ZkGq&FlCe}xy|VU;iS&u6oIqsw>uVnD#fAFNq_9FuIy_l? z@XU94sfFBVzu01MHP6xBNBgd^43`Ay!*56yomPv?WGoL?-op@4rWne|sK;frt45LG z^`LVfzS~jl>4?pkji+XzNg19@*ne^K-kioqteqe=i^w0!56=SS%wQpt{;LMmZ7MJ0dgmHj=AHa7S)*)+9h$v2y^Irpns!zncx z8m+XbbuMu_O-KrrS=5A)--9|AbZ2JrpG7jdeAx0a)CaaJ9wq0u8vdd zQOl6YFWkjj)hZzNy1%DbtEkw#p4zl>xG9OQs%B3mAUr-zB|i#JxE^0foi0eFWV!k@ zHR0Ry;}Z1!-mh~l{xas z?R6cyFkpm5n^kOl%GdK4Y0Ah$BM0%Zy%4Cop|E=aSr$x`(U!IOVCDO%#k~yxN^`V_w{t!!tF`N7Z3LSX*wsr z^^O+SKkB${QCIJnPi+puq+YMt3wzdY_PXcC2uGVTN&)M$T&lXm5i}chLdyG9@^-HW zE7okyL({(*tLOCN*(ug87nitgGH;H2f1==5$k>U*o9s2KOrX^-hlfjP+DvAgw@E)T zkBu2CO;H-SF3^B5?EdG?zmwN4@ts@kNcfCw*^zq*UF2t&!nPeg{L;$OYvqgv6xgD# z`U{LGwBu!(SB>!)(NkrqSxT}5^hI751W)0MOSj&a2(xxfvR~e-TwVwz%a=P;8I`HP z`E9PdYCa%zsX-iDX4ZWgtHs)>=M=Pwlksi%tzXDP6+%7NU(KZ(D9O^%(Xv8Hy?ZJB z2!X6;ld{Fy(SFlIVt<3@?<_M(Dkt1wIm+C<8A+?%TuETaI6!V}ZnD=OH%-eQE31EhHftLRqmHj90ok zl@`u68Of0=s462`P}Hu6r*FooOzM`kPu@DKmhbil1#M)`Mvftor7Ul@&yBw6qkyu6 zcXNQOa@qb;m{C=pfO9X^V~f|nob;5NtDN0MAE=w^(@?6!9<9iT#u%CXexW)%C*3HT zM;jb8I056*(1(yiolLIU)}ULYu%+~nY;wT z(&6@$t%dpFc=IHAc^X@W+^fp1u2PvY);%|4A!9Q+iM{~xaD3y@?ye&_3{8S6p&^Q0 zFVPT%Og{#D>AVhyC{Vxl*pbWeWiXs{Ev*Qi05 zNR%4Qp@h3%`NY!%wvTYQr|wd)5@7bIr>P5QuSi4?u;q6ujYrkVmxues8_DqvL=^5H z&Xq|WsD8<6WS`Lbt<3gO+v^b3J4JAmsZ?^=&Yf~EcX{QayS?b;1D4H!NsWm5~Ud3l6hMao`$ce*x>XQ+8vy@eRRFGpP{ zp&61Q?Hn7Z`+he})H@{RXhrWvYs6_tU0Fu|Z_-*N0cPc8;$h+H=lX*lE0Z2vwA~N#tTjWf2%TUEki?NRzPKn3DaQ$=7boI;0mnzPnnWynt z7|CXM&Vf=zG-F+TT`QIeEbBclUcaW#Tp~MXd5Hdg%T24^ZUQP!8f|7eSDyJqMn*a| z#F@yr+!)`O^mg%br}n!vop21*ycw?lD8OFTI!)L8z1*?dj(W!TI9XR^W)r#k1e4ON z3b90sLJ2D3)iVUsg|&PD0=21>FNoMGIDv>f&ftEd<6{IbP$3`kG1 zP0{$smtXs7ZJE&1`gT7>o6z%-yP>>FC-mXUy*lV62oXp)m9APJDp5Kh*B_65+nT@q zQ$5%cmwCaV^O-1XAT`T2T>x7~bVv^NWUuc8S*RHkTkLvh)u6~1DM!k&)gNlTU3y1e zM;@);hZ0I2V+tf&FB(TYx0(KIHt?kF(^UQItAf)2M7=c$;r=bNckIR3qO1i)C6;xb zf@jM&XY|N|PG@OOLl2z>4FbVpXcMfDQR^CdutZk-O z%v29?+KkShyygF5JLi0Ti3F=lzmwzcRXyVOE658G4Ov!T3Cr1N)Tp=nNW1a7ShM0w zb~af)=ix^O&J#;KGne{B{B<-vNp z^Nze(;C`yQ0S8hEV_P6nN
Pns2Ssk8m^1g%#ATaj9ghsOD`!V+T1EC#%et6K)X znJxcLI^f{0=~j|WCmPE%2{D@Z9?PTsqXB#j3?&}M8nMvDjxgNE51*M3W7B8TzK*E> z;K!B!A#&&AmT5$@{Ak`!D!LjrdNva|<}GI1cl64p-kS+&`+Qj&T2KpgA#9@5(!DdGL#Evu1iZXAcVB-{?A`m%yZ;Ygwldwm z3kUpZl#W@}c6N0MZ&)?p+qPYO8M7o2_VG$P>53%Mth1SsbdZi2IXykSJm0K{K4+M} zlg4Ho^qgwET#qWJzP`S0&ZkMY!J)Oa6^@D`<*}Pje5T2MY53yB3o)^FO#^T0fEfOO z;9%dYqj3v`IB37bS9M{(7`|8z1^^j1LXgW|_MY5zx0m`3`T}&NoWon@2}SOxiVX zqDcj#NcdfZ$1#YAi16`Ug(bUH&H7U?@$izaub__6=ifJE^lkRxx%C3Y?48Zc3#j?P z`nA{*{z+qVeWY-upegaG#ZKYt*RP>&OUUc=-;4sE)8C)}{{3ru%cS(->;6jL?%qky z9S565Zr|(6?(Xh5YFTFe=2K+@*)JApTvor!vIIQ`Q`mmEDcyV5yzazz=gu9m(NfKd z=T^VHpx4Ogq#0aq{}ihhLTh7VV#tMYF)$3b+KjuR?%ccIe6iC+=6ktEb)`N(OTz1r zXEu<=@3I9u6#kHW%WxZzwHffdpQfCVcvg2z1`dphZrh18*#dN*Bh=_=T z6%Dm?tJj&4iuGg_H97eQ_k-2(3WFiLwcnp~+Rj$f(f?|?Ez3op-`%l1KU}Y9-j{K5 zIy{;4ovXDPm-ObHJm_O-o7g^8o#0q@~~BV7l6$N+lWP^#&h(CoUyr0>&5Q ze2?U-GqagSXXx%PK&ZwiZCobp!(aNZpNteL+Rn9l!`YZ{@TJh}?});}ZJzuJ0QqZ2 zvfv-~w#bT1%*;`aJ~v%^h7HsYu(7dW9wzdn<1I!C=w%!(+T#_4fP*Xud7l#tdW@P? zo1UaJmJ>N!#t_*L20teH^5$2Lda1@(xt>m|7T_E<^$}i}sKmWu#A}*iHkjHzu4p}H z00>Rh+1gH4nQqK*%B!l^!ex(%xV#YB#G+ze>RcZ0%n*@~%oNC{@60sqPFB;(U$jO? z55WXUMi5|}}Q3%=#aJ{&=NM_P;*-o#juf+nS|p=M;fPQuyq-Q>AKCqqjb>FECU&}Pf2z9u9hnridC z`TY6w{QNu^6_m@Z3J)co(R4F4H#c8iU7c)CnK#F?7_`DOM$5DgY{)9LKcy9zUg1>z zrl+H$azU@%nu3(L^eJ> z#ACc2UvQGC?4~d?*k@*Pa&Uf|-piLS{r&xQzF1pK*QP(WItB+lx9z{w8Ck9U2^>fl zaO2mt+F&_yu5I>x^(}S_qR{j(Jf2BNlI?)O3emon>d)jqX%EHD1v>+8 zqhs4*nsHcK(aHy32N%Gs*Em{jKD3tYEA(Tq9iLejjwQ-1TiyDV+XZ%h1DJZ`JFxI0 zSTTcVujOQx-+h0%+5O;mg~42%tpVi|K@TUGJkv=CXCJ`_`|eCPynp{5EjSmv_E@PV zTr>xvixDr}dJ7D5eK;TNTo;ZO&dtf;vW3N-)HF^D%J7C*DoLNc4*YBg;Sex|VUqy& zoR{PE<(Zh%!uP=LZYf*aU6?px0k_W(mf!xV(W( zGmVL74eXPM4G)a1!9hU`1Pj}Sh?=25i>Y3&+hDiweckmp=iANSpR&D=wV*ZCZkOOz zYvH3fEmQsS*X-q{@vc*4*sz1_>a|Xyo5c%r?Z~BiA*}br>6KB7&iJ- zIlxJe6@_ruO+Z+H3A}gj-bnKhf#-UDGH7Y>R6P9DxvU^G5~^QT7yur?{)F?55=2m#CYRk=reLC518CZ9rJ{5$fB!cxuoYk%_G2aLYeU&&d`@qm zy^wmTHR*}Nrd7cB^{3X#*vN=RA>(gS8*TH^ZylI4BDfh0%<5of6HG!9lg^X%>G$`L zp@_8_{FgzaQ?cvr?DkZhFC6_kRcnP$LIU=@1Cu46$(I-s7Z;btZQBZmQiIK30%UKo zF6dD+{S?f+31-$DcA8X~$^!g2n&TeH#x+SxNx4Cw0y8;mySY+qjZaD8v7Y$sI_G@? zWl};yLdCA6K+zx|(aNRR0k&`9_;3Cj`*6r#m~QzrPI{nc;A30umn78HyCMjUy;#>~ z`Si5x_LrsCUgBV58`nV)o@4ga1($1RI1i&1L87MqcCNK$Z%LM=MNAAtON{XKUKg3z zVHcS%Pv#)Z_!UfOXBIeq4*;5Kw$M#xH>vIQU_KIe9ow^j-sDDF9GB0jK+PYLCo(`wztH;+pG%dp289L)qX;88Gb|XG^__&sv(B0a@&4 z#*M#v;%Td~n0Q%prt{T`RRF8pKF!XX8t?BSnGI&pxAlV26QwWTiyT?s0=Fh6CbqHr z1_o}WH!;3KUELcjB0^rXLjN%;Du76ZX06vtbtev=QYK2~rTB4 zmjD|goo5jLVfdH2fQQg9GRn0Mitnv zJ_g1^NXF}VTvk_ii+Q*W;N<14vQA2efGH)G$>+@OvNKv7e`yaq_-ucr$!@`~(gJ)7 z5U~_55~%uJ(@KTVb{?K!Dk>)zamgkx9k=R=A}t~omP5>AM}8z?Hk9>t%T}PM?`zV> zXFaJgCWdXNi{XF~#jEK2TZaBjGpT}X^%IAVNFsLEDX_bt1-ot;5=z*N0!dmZaCZlJ z6X~s8XY;oCcz?e{IXCFn+EibktWQ{o_7`h+2ob=_Z&l*y-V*GBkePXV0&$28j0^M^ z5FIHo__Rd3))F?wRK5gP!@CBg%<5MT5+-1vb0V*#q{E1t6hz{qI@;tyZc44ABfhPcBBrGNq5AbG|p;>0EDnlUamK zx-lAFf-!(HfkVX40Voi3twC;xi z)YPAsM1>Rq2>`lCo&#j0_RTL*7lxQLuD5)qy$Rq@F3!)flsERWK^+5$Mn)s;gpR4Rc>`P1s{S!vV}0-l4A4Cn_11x4{q zJ~9##Xf$aJPeSku*PTSR7{9>4qDlB+(_}VbI*vHVvON&-X3eS<`hrWf$;p}^4OMUG z!j;<7n-K5c&*FDwKH>+#lVm&$3@$MtLF_i(O=m&SFW-Fpgp-wPtVo#(mhz$B{BHjY zH{Rz0?UTI{S%;XS17xMqxm4Nnlf|FSiZ*7?kuf-a9Yz4c$F0DE-G*ZyUm{QX9Uf2F z3PU)+Wl(zqAqR#V1|@Cvv+ISSN9tU?YS*U$vv^;=?l7(4ut2{Bk49B*1wJ&p!|1F+_|dd_S~@=FScQQ>>qL_)3nnB# z<2wr=$)f@K^kioSVmnn1gx1rGi$3{%9Vx+~_~hi|%U zTN_Z^;+slNi(!+&4Bmul+e}`z*D0VvzCu?X5g0Zf06rDcxtN?6lMCp%j{MauK_d-- z^^U;t=}(?pI&DqZw})VM765RacW1pJd`SfL5Xg>v)IIj+ZXM;M9ry`BSe|AC98`p4 z4NwnB2?^L@)Nwj)lo1u;vNKK3q3*9-((7vr?h#mmLpI#%cCcvTi@|RIskEN1uZ+e1 z?oZQJvD8hYV%!ECA^=wXOkq-@SbN)Kjg1UV7Oun3KSGrpYz=`CicBrmq|@ zWlGA$U@{-p*|U{o1Bu8dD|S4~TMt03ld&1KL-cC|YH~8`d8?SgsKCX8m|6ziz-2Ri ztNGy@`9gm7%ZFrgzL}}h_4InvqQ7Caz09Bu+|PmSY_mn)4IcTf*->T`;?R_ok_$Ik? z^VLVT#1WMMvplVBDtX4E?Wytc@jB4T5IE)MqT+B}np!|b?>mE|5xP1Y0Y~8t6cE%w zjIG%##n=T$y$yve*YL9pug!jYGnwpxS;Rm+RIf!SR@__oOBIVpab+%+r zo}|qPKbWreI@<^JPQk#?d)n&f?++3&H6Z~96Z0FdqTpR02)ho<^MKVGP@=`*p}=tR z9aeffw^WLC42_J9!6J2XB_ibp6MG7RkCG8il+>NU(5vpnQ7|(06A0T0n`Cr{K7KlQ z22!f3DTHouKo9P=wz~_CI);-&(P1!hV_PmPT+T)@kwM)k;*&w!?8L2ne`H=)s@HT3 z!Ur^Qq%C}sX+imO#bF>z@B(s-GTsj%<`@F6`yTi(^bPd(U~MRwBoroS26nes8h7=k zzJ~@vkNLrI-$O=Y)UKgBY8Q9C_%NMEC$lkkZo|FPpU?XsFImU$GfW_e!6(3#(>62M zO~az26(MB<8VPXz8A_6XhSYXig@FJXLPAJ*22^o(s&2@ds}J=7p?C-ecid1X z*cRJt0}K&Zc9myxF?0clkh=PF(M3S&dKW2V4D3Ns0YA6~F>enCf9nLko3T+GsY%Jw zr7#;QkniMga@~!Jj7+Qmo!<wXG+jE%W zYV2fK+pf>83P;xMc!<%03oL-W^g}jawp_0X5T@Vc@)klO zj0!QEk=RDm=7vS07%yr+683xI5J z{CCeW@jB?9EFSyCR1S0S{No^vnr3FfQveVlPxKyodF;x^oLma>9Dar4HvYoX+%AEl zbVd7u6M{6)ZOqXbyv(`(8izI%?~2}pr2g8bRfeLMZiY+O4B5j9-G+UTcDzRS12bsr z5I=SpX?-^Q2H1ecudnnalR@$T2pQzDh=hGE!4GY_G_|z}8#D6prG>yVB%(>>>4$DJ zOdE56*Q<-Ar6q7baLOgMyPdD^4dh!O7{SkG%rGTdVPIkHZ$MfD=;bYL!Fz#Z2dSu8 zs=>s|#jptsP#s7Re7-&1z(GS}1_%S9qsXyWHVVTE!{7yy%lrJW$Ifq)lUmvm=w}oT zJUb2M88XkR=<~fm^#UQpa@oy)g$ILDfP{<3*LQa$I<6q+?6oyf35_I!Bn$KfHevpO ztVYDp>V%H@pv)!cCotw1A$!Uk;NOE;g8WSl`#^P|+DEzBZSOYM0=h@9(J3}j9kLRs zeJdr9mAb|OzoMz`4D4G`U{9$l4VkY;h`^)@J0$p;k<1I7XzZU;V2zA3-W zV?j!&o41N1LkovA05I`#8g@Nes*5)AnicZL$Cl-nYY4XeEctsO~ zH zT})h@trHZ(Ooc(4qVQD>@aLP0xf?uYT^{HVWYii~^1z(*Ama(?dtloJz(@;68bE&X zX7TD^*zRCqVpf8^LKaR@=qwM$BQ`b`OwRV_hi|vwd^DembDN221$={zqCwLk9}G!} zOF#~kft+{kK-w2LJ0Ee>{l`y0-sSc0booxYW1bHG87%hMt9uX0&WZ`KE;62mWf(?k? zpgzCD*%?$+x4Fw4F_be#PLIRC5MNz3MvDQbK+(2ln(ByK`Ux(8Zh-q#DSO41TTHk! z;j(%m(YI>^7sa@>Tz{(0&{=;OFtd?1zRHSgYRX1=^Y7@jc$86BkKwAQ8M6B9C?Ug zzno{cX56O_y;@8@u(TTE98p;}>ro1cqhNG0NZfRSwcf4-)>x0>B5NSpj)MkO-tl;v%zt z zQ{`xq`;I=aAUUun_nES%PCIjo;gIKiZJ8!g4)|~vM|IwYuHfr-6_J{j%%wG#j;QGO z7JV!?zkmS5^num#T#6hx$#rLCHiQxp_e08E*OzQaHpw#|dQ}$9>!wK1x^6GN8~o#S z-PFwg+nviaN(aoEz>Br$<1X94GDqE{HLQ*6A&ZHv*oV;BvD8W8P!#P8KzsCW+p|Ma zRK?dI3z~v=E##BHUx<_$2SD4JPCkbe2|R=*9lfIr!%%Z42&1=Sfp58-;Hfd{MUEzYzzljf#Y7<}rbhZcb8%*&BB?9+4aO&~l@l6+x zN~~%BFJQ(r8QyXI4;~Tz&njs6-_&Ge&^M9U@nxa~Q&CWejMO08i^RPA@9~1={(FL$ zlnl`?#NK-f)&P9ev)&t_PCtY}oAJJn3ulOF&GFOF&`?l(v#Fc1=Bl3bLSOTr{=VWs z{q!j?<@A9h@I1&)cw%a^{~2_i+d*$e{GWsV)CZWNG7dE>D{Ir*JKgR7pafPw>#w>) zikeYwQ8ND@l;HjklxWFc!sMo3BXI(#95?L9* z4CQwKGY4BSOg~g;N7KiYyLEjwN~ZLCBb<*N5m)TZswX3{5vl(7w)y?;&VK>$|5PV= zTktU5x!HWAZ1CM%nsL!afIDdp@rIOoLI_Y5qmH#*o+H-sxzl0eH^svwdD-v?Z!mAD`PAnRbGB_ocKkE4==6 zVe{Wx?52G}Bj>3apL8JYDOhEIGA;Swqt$s$)QG~3hc;~{W}nCJWG_T4v6{^by|cb$ z-^;TuszxzHyvHW(JZoYJiH8*qo4sqtS^TG0BAHH^PAfb9amx+I@>8ycd++rfJw7^P z&I?oQH9eyU0p&F)ourn*A&%)M^T4)blA1Q#t#RK(Q47xwF&@EF& zR0=vCXPg~H^etkE(rBFcIbIKi`uce8)M>R`mC2^xor|lyY5nZ8I{8$b99nI6v$^HuZ4^eO~y3u|y}V(vPGXQBV#e(d&zj z5Qg&0`@-n+W?S2yz1_Ao@V@Q<1($d$kobskO}Ac%;e}I^vnsWkMW9gn`ufKAh>5Xl zMnpvk*)IkZSU5OkPg=LyUwJ@Ks=T*s*)>jvUxFcO7wMP@|1tI5toPi+l9$^JG@gkicqHAhW6(kNjN8pzJYAV9%jOra6Mzo zXnb|@i+DILN2*+g=)BCB&k?!9<}5U=E438Fnrw)ACv# zx$)*(3*=3X*3%eWKHRk#|4@WF?yj`=;Z7;C*kKf_1x*2~N?de?my7+H@Sr=FXWrdq zWvR>S4ubDpNEW(m7yI(GeMv<89bE}HNxKpqmGPPq>TGLEjfNStC&%rd9)nTPh$}}R zMmX-gt$yt09>azijv>0{wVA|ZL38#94aLk=NlNNLzTBp$*p$yrCL2vOIqf@l`>(R# zQl;3b56}AZGwL#Fl0!30hUH2MN*YV-0(mfFV=>r|4ctG(Q2j&1w2#Dw4mHdpuacx3 zw&&sIYP8RUsHz1XG;v+^e4?o?UP>m>@fsEVg-b)AI@#vXmVjSmiqK8@)W`Ly>#j+j zV<$oCw%7i7;S+`H&*q;VPwD&EE`3Eeu&6H^8{du4n*42o9VXg1NI)f8r-*>iExqjc zxxfN)5A57jP}s|}Hju_$q1I5J`WxZzr*r;Pk5S$iAN4M_NgPz~{lchI zZ?^C*?v94=GZgl9nrbtR=ZW?-0rl~S!+Pv5CMml@RPudM_E6Gkt+Uk3hg$tr%hAD#9yB@tb!h_m+rw;3d#t-@&25HrW z3;jFsjpH)~{8AW1Q!cN?Duwy`2`YC*PcLnR&v_H*pF^ z7oTaie@PQrQsOX(+G-FtI&yjj8hX^uRE7_Dn!kF2;POxnO9He?VJGm}cC zEw||~ARUGKx)sxrZ*{-24|JjS@xy^}63|6QWnk1utLiDE~vecSOq zCw$k8!sO$6{|H5dS)R@F;GpiRRQ}TKM-KzCMDuXVAeUxY4y#J4-HgT`jTv93<)202 z9nT`Oo%XXas?l972=+nu@P?Fc{s?-c)#OoIzUhyQ)@XO#ymdg2{RP%O1SX4Oe@RG+ zrE|Zfq%rz@NlBw~UlM(`yawz` zgPir5>BKxmmENB6CE>jO1Lg=`ZA5&ryfk&s&n;48lw*I?t+-0G=ES|{cuqUJ1*xWd zX1|C|p&ny2m6Xy9h-ECNyvRwwYZx`~-1sewk016^{yerrl4dNk#&$vIteCSU@NtRs z3LlGlo7zp{)j{C0@e^a3q~@>Wln=@I_dOPqeU#?TmtaviQ{}7A=HJbir2%?&yg2dS z38^}}^BgoYRA`oaV`G2x;x{WT>hf*1* zL3(#5GGF(<7{+7B;>yg|S0|fP`l>kMi~_=*&B%Q%ao0=$&=nNT|Ud5pN-{#XqZ& z!ej?3En{tuFMkegjb`-!?SXwFrMikHvteJFZ)k0wYmKg^|Jp5oV_t)cdO?-rNupx)RH%oxm;na@zsiK+d0=2BrRBKB{qpC7aa$l|Y6qgKP zG(HBw8}?L_aEMyRCnvPvfgoe&xiL!b&OGQYHlXdC?Iv0PTZ;1X@-%~69AGVei6_Gm z%V?~kE$jS1(YT0WS%O_b#oV7A9Y@y<*$sZyIe+TV#+KME_lkQveEeDY1Mx96th^MQ z9H(^ynUQRA3QiH!bsF!X8o_Mt^UM9vKK5^Y|DG%td=7hkm2^~nHi=XIbm+)WLiinH zUsGuIR(4N_E}!avsBzf=x_=PMCU4z1Em>W9AwS`GPaZ0T6!SDKz8i+0nz~(P4dU?* zhcL^!@T!09wvHkR>5zO$vfVl=RoSgIMF*8zBs1&hB_vjbN|k@9Gd)PMC-b43T8t>> z0X7wjQBuO*$@O+Zihi^D;_U2fVmmB{RebMwZ|~qF8F{YOv5>ffR<-`y%U~s$FU54d z!N#4$QDJc{(kmZ%)~?^bpOeql&$m>6ba24Rj&6dAP1#^oB-qxXFvRCS{@BO;s!u(u z!l)`mO&(X2j)vNOKaq2tjGFt(KGVu~cU%j2O%a6J@|If0Zvk zb|oo+Cg@Q4%Jk9MJ%o3XVQgl5`{x@|a5OKE5_1zRHg9UmK&GJi&K55_8MY`QGV;mB z)m>`tnZFk&+o0nciI3Knmayk{>2p&h|GO4IQIwV9?&TG%-Y@U1h@!FYT^Rhh?=VO} z5l~Vh%%ppIpct+u{>wR2rK&Y9T96CQyybJ=B;PzRjG%UXoJdHHndp^cazfUKJ|}d! zw-c?yh^MY9{qd28k(9-`aeICECe4if4BMxf8hHcjI4fQgH@?)1L>r~^v)ycSK2>#f z(mQwV$Kbj%?=^80MaK`OPA1>Dl!*q~tBQJ8ka>H*S^3$fXW!$0K5XJ0XMG-DoT;@? z&XaH5o%o{yy;SM1K+vU##si8?M3pNtgm)JYDDJlkyy?ZyMn=5vpr@F55qa_N$3Qw4 zVS~Z=&J1jIpbKEt>W5VaV5O9l@*`^MM}Puu0Rv%ScSNXgHk|Wmh=Hzkv`7P{3~Oxv zH1(xrWa8vGt4htA&`4TS`Nrnvgq=&N@|~yyGvv_LjOnEc{5l1)wd#f00@(uX2a)i_ zOYqjOjCW_lJ%cqKJ-49AaeBC?Tw;^$AWBi)y0XOd@IE8!VAeH`L)K6MRhq`Ob|P#>q;{XEkZlGR)sr;5n}rvTAdzy6Ou~)W=U21=Ddiq zHKQS{`S_yrDgyIjFnDO2k2+fuOOcYA#q76xTE?cp3Uyk#j{{F)^MbNyzX_wjH z3*#AK8XXsxOZQii10P)i42%z(9;N14sT@|vp~hVym9VTu%a^II@eDK4!@Q>4B4M~P zo^92R=kDEmu!Jy3(8BrV!>=a?fBr4t-(2ggCS^DVR=)mWusu`9k}r2eFG*BVvf2kV$8Sycvg)U0D4M*b)(^$fc2*1zd zUT@DcwkG4abZm+>S;1_f(3+*<`8$1%q1l6>segxjMfv&(_7uHZ{VN`qsw~@TTAKk- zI|;fA>DmKs`H}O3mS+5wlyY;tOA^mnrG=9&y;oa;}^h!3^O%t^? z8Eub#V>h4SM&>SItV8nA+dJe#aaxnpla6t5SWPaw%=i{D>tt)wX4O$7H_8qBD>Po0 zp}Gd|*<3($e`G<=I{xt}HTp3j*I_Pt*B9TNh5|)F(=V@5%-kIU_1f&qO71xa{8HsPE{?yL-Tpjgb(^hn} zA8yYHANlRa*bv7 z-Wf8dxDnw@9qS~#-7iHyo4?;GUMy#E4vQ+uHx6}*v6h*xB8{^6W|+@~E%636gmN!t zast`Rj@jE-PXhN6yQ~zpMJ}Izz1opWiDkzXQfUpQL#Ocx>*!r0D{|y4_&Mf!j(Eza zht`p2#=jXK@z*q>pTU(kf&H5%THCeE9-G17q~^($dD7Dv4K?beJH7LFOfIr|aRPCS z+L5BeuB_HR8S~o`3p;I&BAG{grpsd=MzR-4i=n#2iU0hd+&(r>)yQu%$Pk~TcW?7a zrtDLf^m;Q>?ND+ZG37arh3nezvm~@VuDBPO52@9Wl@Yp^2#jF~_S|cK8MaX2_GupO zOclC^<8`*Dvy*N9&XrteH=XskiiCx{zG>UJ*_PdA!Sf&X#(@~IQ2bG*-q+Xn?C7;B zcQpZ$0_-iQm~9D_a+$w$Ug<5K^P-|fT4}u96{1)uR}LbKSdq?AxO3wD)kWLoWq9UP z-9t*=F1bQxy)BoEmzPu`T&#&~M&(cO*Yi58_B5AlGwLy0sMA7t>^pSml?q;j@=^B+ zXwfv=)GL07-m2g`BAOnr!yi*FWY$P!6IZ z6-@xS`T~-F`TdCukGmZCPexs%gH3u$FH_aBmetD64i_nW`>4YSURF2`wZlyG91F_- z<+mZ#adti_unJaa*E7_>UiZ`GTWZf}G!&x=RFmKe48g?z-fqjkxb&Q|I#LP)c0yxi zIN{qz#@eCUZ^?YGwE9zakDHOQSUaV~J#fCT@4f~~b>AbUwYz#RD8dnE4n8XfHS9a^ zr0O#1G>$IVr3|cMcST1N3mFZTO+v}2XO%LQ@w$NCxYby>pzDGkw`A0Gts}m1zcp9I zt1o3xGNae%yprMR?%rcPaN5XmIcsXJz zjmA|;{iuS&y!GllJ+V&{1BJ%C*9=OOt09TDoxN#TgDM*JLkev;e!%{TMb93&RQQds z=NbvEc!s1Rx#=(qx;o5pCQa^dv9VEs65IWvx zQiJZH>8_KR6M9F@zN7utV&1q;liR!ILK9OT3(O13$_)g21X|&T27|WLD?T~oH?m)t zyAaP-u6#{$NhU!^K-$G$&Q2I!t%=Owt5P19$eukbUf_OlkG*uvQ6-PJ6bi!fNwU5>CZfPEg9etaKfJe$3W(MF|Pryr}DB8+VazD)Ux}(?OQK*1F`vNEws1eAb(NFrs)J zjDXou>poJzZ`1Ng=VXU_#=#NFniAF9StCYAN9$&NF3zAb+jdoMgmtRs?MSKOuGGiC zxtwna8TSw5f;t>RPZv^cP7VxQ_rkP2*pQmUk zu}Zi_=(r=aGLDL59?9%~V*GR5`LP0j`4c{$%2z~`uLKi3=%15z?vog$IfuQSWiF8x zloZV&U0Oc2Oe-}QXdQmnP5+pb3DsIiqjATsr@%PBsY=jqlU$y@W8-ChX*99dWs^S@ zfBy8JQhkXWY=nTPHXYYm8FwiLjwH>I#iZXX;`J0LSy-1;k=;pEAgo*~l&mYZo5%^O zgk2(#Vlb?KM;fs9+iJW_tI16M<{@*v{cs){2|sJ6TEyJTXaV@aiPam}NtHNm0IM0N z-nhpJOl;4eJMWJc&kbIe6yQjzZ;`xq3Y~$BU7}rCj{M39pO%-VvWYs>!nQ^q!@kdwjb6{n9r*+(U z_w-Nfwq9l1>E>Jaz7yOt5#*6|0}~R%|HjBM~jEd(H(@f%PIGR+t3HKV=+(51FiiT zW?587n4dqdl^rkV(sAy4jrTmed-VSM5Gf1YGL4J(h@o*57lzZwC*{rD4O$MhSHGX+4$_DU zCm}uv7o@~6OPzg*XfihTT|+QH-MWyGCiMk5s~BFYuDSp!mMFH<%BE(k)$dqlwf{xa zSp~(_b=|sg4elnwAuCbt= zyVkhnEpEXGax_?w`Ud`3NDJ9YZjH;~KAQin-~9#=%JXqhQu=58xQiFSf{ROm2!c(? z>1CT&(eH3NAAov5KlG6`rn#)ExaaOgQFp*bJ;++C5Jg^~w<>{ac{dyD3Cvp76nJ9PC z?Q37m3yo`JE@9Q?;MIdA^Pj|oZ&tTS$%uiJ&2sJ{o)RTO-V5)(wr{ECvb?#7l9T#a zp#d!a@*5Z~+7vnA%?4mn;}Y@kkmWTLim=o$)!~SZ+g^{-9!x7QbZ5T_dK^u+DdL0v zGby%{2k8CseJYP5j){o|sU!Jm&$D45`f^&1x7_Tnx!#QJ(5dhn&f#FF&ol;Zyg$g2 z2>=t8F$>MBP@A{E1h#YScTav37c(u6fbT)83JFx1DmPI4F{rwlS<39QvQcGqNHFIj z1ymq64OR9k>OR7O2&dzWWlnfEf2UK%z}^_!p1QkrP9N`8O4Q6prIiOr*T^y(x1|&o z*e$Dr!{Z#rMOK;g4#mF|&yuhgcAS9#wblsi{sS(#(@Erqvg3Lsi@2wX)JSo!*|{J_ z8S@XvzX7UEMS_{7f8JjE<<-x&^FGiFUz#DODW<}!ea$3BQFUOu&UogSbGS%7w8`1s z3`}ynX)lBv=U!?`Awg5-b66@tgP~F76=NFk_ttgoJjoV$>>aikFOewN^Il&o-5+=e zr-X_OAGwEnkBn4=l9v6XDWYq4UZO~|;oN0aIbtd=zvcozk+NQV zNedS4C`zPei-|$6)jtU^Lw)?cDcQ)?^G9y3sSAIl&KHSCxxXJo1*aIINC(8@3<
  • 8Ml((_4P zT8yF&=4O=TI~`_6s%v`DqYx)@?ZMkex-ULzXEMpVLmsIl?M^GBLSv@9VknCMzDaoA z?jO(Ha}73y*~SZ3zDVRB4y{p%9lKR8>x?EvUvHE7)V;yL(hq-kd7g;l)5l5H7)9n; zX>{k-StLUd+M~xcySwbwZ`6(QZ1k&yq+=5j$l1Sb1_r@ISuKnk{&y@lIq&T&=O`#D ztMjpY4kbQVb}h6kpS8)D+*A-AyY2VEItYLE1wTW1VfSb zq%lFODw(ZCEDow{-w~yBFRr#vK5o&tEcyQA7)+}R#Q1}OA(06RB$0q;Qr$?xn8JaI z7*INjxC{#JREQGi9eu`S?De^|5(v#)iQtr*^XeoXw;_R^Gki-#zn$6TMSEO zcC~G4)0`98(ixw-QG(RNN)M^`>*Z#({N%r3H!H~d>~&DP;71@`fZF~PrcPSm=w%9H z>HKt?-z`4JbEg?cJ2$T&ikqb2@5BnpQq2BfllAQYa82Q5GON{}vfpO6UxiGu_*ndf zJkvE+Qm10O?Bxm8l=mNm7~^zHy1;si2a&dpa!8 zdG&4n`TLH30&zb)j74zO_Po5t5H|wuE;(h@qOdr-tLZ|gq2{wH;Y-ir$2_9EYLbFf zPDT!!-U{sU+q`X-YX#Q1y|iRs1y&fZAl36Hv)=DW)dlam#o%Z538(#!7tr?n7+#JzQiT&R1EY7SDWqwG_N&QP<7Q>+~_}5fR$h#M)7|%oW=2@un6%f3$+JtfweCejGL)O$pNGPxH|( zdzxg!unyt^Q&AoT|9jkP$#XI9Sj?=fl-_5$CNdRW{AH%Pb@4ca*Oc}Rj)xxQs^+j( z`^=bswPdWR zUuqLX>m>iHX+}qs+soi=|&JD6eQ0KCw|Vm(YKWv9l{`4| zwcL)#vE#Rgt93qyPYpQ+T%HQwW48QS!y}YZAoP?6ThnRihFvHZzJ!k}70)l`cZZ8`T z9rn)LQqap*6Ky=UmtFJm*|+O&X=!NaW9ja0YHBJlTmrs!cXtw|f*}>jE-v3s5dV2fnxwh>>Zd=Z;$DPpa zXDu7Na1Lt@;Gme?x<*^4x`@AU|DM<8zEEsv;QSR<$$XhDLljxETnsif+>GcZ-P6)Q z!1OkD%J8FG)%^LOK!#*<*U!66I(W9nLFj1)l5B%=a9ST7XRgot>OU0DGlv@1p(Uj7 zc_cfX1Ox^s%v2_RDl8ZVuN$bZYsgvMT)xhw|N7XFxM05wVX7-oB*OZtkMmN-235tK z4FzjB4^S<*z-ms*DBH71H-x=}wB#Lp4fsAZrYk{;;|~kVsI6VsDB@6fZLdItd%43_2fdbe!2PX9?kDTNM8?&!U?cO-%&mxqlA z|NgM&D}_i^w7;C~aI#Wjy90km)fc_|xy5?4D?~TkM1g@P&UmrK!`r4l@h=8>PRm8k z8VxHuxDZw;syRToS-Duhjwt{66dgfqNv~8sB#|&VpD=w&_=#J{RrggQG!YaSnbylD za~HD_8L7=iNyVV;FWe+<Dww^I8@FJZivI z-sALe3h;NmzWS|62S}O zwt(STnjEo>e}VGK!LZ3VoRG8`|3C`mC=!}s-5ral!b5jMJ`-f^(_g|PWS&zn_-DXR zCSFBK!xZ7d&nb2<8CoyRy!<`iB23 zZ%h7Eh?%97YOSCzOPJ8GlppFSAr@86FSD3Xn`*;O$}X-kiZasr{jn#x)SyzCn??S! zM|nc_o`E2nmzi2LWHAa*xJIHgT>Py#4<^E6z~49Rd^2_M&n)>kqKl)Z3xVTI3*X%FKTdb{>3ug(6v7oXc?Z@E}n$m0?@ znrJgULM(_Pcn$I|@NWfxv;r$QO1`qKlqwRf_G! zDjW*Ve}`#FQ%t54^&&R~xdGpnB(RmrIkabksC0w+j0(Ys{Bo>fU00@KMQ&ZyE_ZE4 z1Xxpte6nIQdxVZ%I`z>#{vdJjWK;*{y!yt$I}xlL@VqyZ@{eE+&=+-%_>2Q+Ovgzz z7PEA1hc84FHZ0y-e&k=Zm_eW!5a>+3S%M3SMo<$9G_t4rr4mZK@2~%k4^O={2WXQ= zabx9XDyonf3Eoclc}e6X$)I;KtVmH4Kb#izu1=X~dH5?dY@LLAoV>aJ_uoL<4!^!1 zD)rG&R5QVuSeP4}hBEvT_o#(0)e&jiD=lJ^qP1MV=yD34XY0DeaQ0 zY?2`0?hn2y3FPw)HVn&Qp)7X9#>(kP zpfFd=OAWYM8}#PZI}a95;c_qvrDAS-e(Q_yxg=5ka@#3A33r2h9ZoN3CN(D*xh@h+ zW@Uho6fh0n;@OU-_mixeKIAa-F54{(r!1`#Uve2%09WZ8zV@t-e$RQa%G?-RL!t-a zZd2YkF6i}q7swjHY5v_7RH15122|ax8oGNrP?ASRkp>PAgg;9D5SZqo^i^gL!j1IP zEj(jnw~J>%r%H>nj5|(!m59;i`s4>msDpV5O3aEM8LJwx7W?{Fpunw<*(10ExNvBn zx!F{4)ACsS9i9>WvVGg+;?)Rzs%;aSB9rF{cLuJ6rt}PmA)GCLUpF0J|J6iI$&er=5olDl8q58FAO~0L8qh(Hmku7> z!=J(E*_BYRH}=L)d_t5fb4(g>LY7mg;&|KdseAX@l^R1wBygP$IifzdcQ31c#Bcw( zicOzvsR|)Pg^fShbzEtxuph<4Jd2=w*hIFJJlv8UOEx5rbr7%{KuZRPPJWn>UHN&{ z>Gv`@C-r|?fOn#4R8YRY=OwwA*-5W#pv0^($?_MCI`|}wQaqmMDwj{x9*qtJJVzr& zdqTsp6AR}P_sQ&mPTD_^-C9P+X^p0Knp8glQJSaDas|8`4hSHntft!;^B2%&+6%%< z$k%IGCUuVon-tHrpS3sXjs*>_Vp;2vQ7Tk|(3e(*;wh+4@CEBDh#$;BfI)6tG|3PEsp;t*-+9X`MDI2sCph33 zWdhwzpnQ`VLm;FobiOzh=&~GzVmVkB@mOf-Ky|2$u*zDo1i!?9LP>^D&%@eteZ3x+b)fWlq5oJ zEP)%f`0;Mp*XV94G!{EuOm_BINa6L;4n$I2xz>;oo@W~WZfc*5IWD|};VhI<7+=pvn3K;wO0^aIPBj_EHad8@xNLWSG^ zgjffv_HuUxm+T`!vDR3nh6fBt>U}RQPc~1r8U>FIcjHO;?jsEJy0mc&!%6?F|qeRfvr{*ir5CGZ-ET*$GOx8>g~5UE9+iDS8_qM9Ya4x7wMDoD&&b- zjIjCy3<6$|YB6Jy&p*5L;VlHSVbu~9^zk2Y^kOP!I}lphaEDtnlJ*ODbgXf3Z~&|T zpiZf>t{RpfX0Wbe7-qNvgp){7Kndf}@)A34{q?JwZ7tYPJ8i(WbZUdgQR{~Jfq0EHPR|xr0$JdAxao96?P8I0Z&c$ zW9;8eN^#JT^gyL=yl_kdiW#Ex)=&3(4#-*`3YowyD>^6%M}1jjI74{)HG8A@IWm%i z3^@iv!&$HK>)c)l8!{;i3fd3&aW%Q@$&$BxgNsqyXjevD$->)pc3WWT5QO+=_x>Pa zw)@Oi@ovtl6f7b6bohxAC5N^Q+?FxcI(jx)1~#6Y6@dQZCe<-+*cis~cGYqsYqV(n zR!tg40|j!o-nh>m@RQI~um&Q*&Pi9^=hgl0$%l#WHie*XQ93MyT^7vS*B*ux9Z8tD z&viSP`0wNvQlzP9l5>*6S8Q?K~UF}N)?6jQ~-}jX6ENJYalLh=W6$0?+Nn8 z0?DQyk>f*d7HO3s19xIecL4_DK?>XI$eo~A2S zIz)F2X-iyGngS|2r%lt`0T0*y3kQolkvL+YG&B_E)n*i2B>gaIlZ3eV0bPr3fzNKv zDU6h6&fyR$Lz6S9et2IeLAYtDtq?Fjk>J-3;%?&}p9S?K5-6leuWd8SK;G-hyBx-@ z&bQzm&T(b6-xuV7)l(m3MlLy zWJ_*6K@VMhDzycTFotgS{tA%5icpUWW8c@GXE9H|H2$owp&8O8B7!6fC#~mShJOqC z@EBq_*}~Rr88{!6rQK^?6YCJS*HUj2ez=4QOb~mf=cR4de{euD8J3j}H^IO(Rl>b8 zjb+fyOigwD=s2+Lar4wyaH0D%V(_{$wDK=))RFx(v8!Dou+5X$t|M#N|89p{jNva8on{-)jvx}f6MAvNd>%0Q?HdgoMUFB$__T`Mp)ADqg|BFBm^S<7I}RBt;k^@j-n0|$nugu z!6slg(|9?FfSLq)2J^f#I&IW;nyQ|z-#_5l+u{1iJ;`g5>hf30^s2tSThOEaCA{T) zqQQ>u*OlZqfDdk*5%ztL=Od&-f*vQ>e!4oN$d!210mi`GBCNFSemUu*^WR}W3}qV^ zEA1|hklWdhcPE%rb|0`+{RbatRyFwO+b;nfwaQVd48pAHW~RmZ{%#bfxOX_fmF5{`U=l^i*ALaiEz{Y5L5kks-~I6HOors4LM0-K&N z_x5U$9!VUv&?!1A?FZ@b)ILk>oWXRW0Za3In9O7YP8uwKvQ(Mx+dl;b$A?OSj(i;pV?*$oKA;Q00HY{Gg2ph%pHcyrsX3x5sLBDA9VHCC;u zyPnUJSX?3l*MSU;vH9nr*9gSk6Kju=GsRT`p*hnxoE^bE2{uj*h~+Ygwh+l#dmS5d zMDuCjuG3nAFb=+`SF-nIp!+Qy2Y&)4o7$G^(q=BptZys*F0}tJvn%HhEeo%1P75O5 zTq=dOl+qY~@#kTLIeUha1R!BZq>n~o#>2BRMTaB)2?X1U29dr=?(V@Y#BGnAPnS$U z?O!6)yOVmJsr7`%6Pl)XOxvJrNvu&ww;A_AgU(K2O2b4J-b|ANtv4AE{)@~uk{krvpLVA zpU$e={&lH7PspR1^_TZvwL}QpC#ZpnJ+joSJ;=j4w&Wsdd~t6tAUyeQ&;<13Kd3CITRs?_E66}ra%wnfXK5i z4Odc6y_ie);;H)Ak%{s6uaqV9=hJ_qKjnrB0b1$P9YrS1+r#t-3yYH(mRWTl#QDX) z?rh_?Fq6-5`wy_8G%Hsnv60B{GJH`BlL^I!H?0|u9h{rg2gn5m;s>K6`% z&ewPZ0llpW%yC_GkwK|7dJjCT{f6?F-mmF--r zrFXtNoFWFuH9e=c$rozJ08VaS(Y||!2nM239_LtquLrS5w z$_BdwDr`DcEIHrJf`F)!{7K3JTV3pbclUPEbRN=0sBiB#KXcfpXhc}+rBVuKxV4J0 zG^cpu!-8Sqm9YA4o~g6!9jzAa4Bv*&!rnC4k|s&Af4hAzQLHXe&&6}zsy^EntyJzs zFFnXeFy*i0KTe&&uVzmV&BZEIXzEfpNEK_i7J2fsuxi@YxgfWla#UxcNIn(|1Vv8w ze;l~i(`==VIgm*76hGe{bXdKf&K(bUH!mSiyXoG}ML(R`xp_jNInYojm}qc2I6tIs znK+5jx~z%TaOGMc>ww%y84!UeGU26BVWEx&hWX~R#**vL4_Ah!{dtI>!@oWh<=VcB zqKFsow^k~aEKRIaAQ6o8e!akb=$3^=49zxu{dv!}>eH5(?858*c|jH5UPgNJbtH(i z#K3(fHG3csM9BGxgHbg1iy>i9S*cTo`-{cX{hogOe%g@7hVo1sDx!FvS(N+m;_6>5 zO#*|RAxzZn^yQzo$5U{Q-FYM1a@6IvzWw14a=(r)snKV?xWlImKR3Jz+TsTlfMPq7 zAjYcSemclwdwp{QCAn?fzW^7~BTkW+n8@z?_wrir3cagC?;cBX)%S!2ADvLxborL8 z`>~fD6||XKQz8v8tr|V2iu+^mD8E{a{;M^dAm;Y`R^cK9nL>AT`WU5TfDsu9OG&}b zo<+!v%<6A(HPH}2c0c=5rBA4bVY?)C!ZIgj+2H=w+<5H<_h@9PtiF#9Lyorl^(DhT zqPeN#ER9H=v}z_TZy(~bKHNtqbtFRlWvIDi>4*HRt|a{r6am(bR~$Naka)z({QLQ2 zej+ni8Vww|VqXim|RlYIS5Y`}(Ve&$|3DPwAu{CI?vFN{)=3N6IRgKx#ONo7OJ7ilZkH zB(7i~M}}h{=b-R0Fc*m!-`(NRfFcl_TIw6q!)?~kSZVvhjZTF1OXWM&ET?2GP*Mfj zowkSivo*Et4Hg>$5%;UR+N)jF_3My^ldV~-rFrPHrj5l_}Zx*JP&-M6vx-Yzpu5w6@!j=5%|RE zKpipi^><{u0!fC?do3LI1i%08Ofb;PM<*~|eMTa%rEhC%L+O714_!tau)cZaNs;<7 zvkEGLi?%FPL(p)%rmk&290@x)xjL%!*-zS)^k9{Hj8-{Y_^N=ej*#p5%zCCfk54u1 zMyC3nfh*oR8xZ!bb^ty*hlbD%U8&EORi(^kSQMGrSpn?tkg1D{Hm3>P3FD%byXpZ! z#24RtK!jIBj&A>E)lft@ok~!ZPU8Luc#zP2GCJ#bwq3vuTg@KJzdKl=immKw*D}Ys0g(ieJ_sY&o!xDdwpEo^Zpb>{ zvsh;P>Z23fU2)8IUYUc)mj$IbBoH^vO^&zqpZtp$(c>sFgUnVYF?&aUJrxs9&k_me zNti+eC{SR~;Ndh2LRK1FhHP5tNhtC8 zZtU&pF;w7%KMR$zvW@IG%~SRLBk1Q@=tByr2A{Bo!oX#?fDb+`|MEgi`llh|xZRzK z!O&(cdLa{=U}8|YsHeGl6&u!%^ zlx`x953Y<>8Y&R$HEw$QK$uHKPEIvCI+yUwx%2sONJzGz+u>y@r3{F08nHT$iRS7o z`{z(mNp}AUNhN%?E02vvkRB{5oMbr3yKJk`&B|h+k|Sots}NK79K~z{jc|VvaJ-?a zz(AAays2faB~J4fKNu8_M(jPq!pG;h4h=%>10a24$2wZ6HG@Ui%CtGDZlX-#kUlZ-?B)wd?R2R48a9H?1 zn-$h#q#NG8s}Vcf$NkzhkXdQJ-P9z61rlcA&xj<9OnlH3>{)HD$J3&GcU9RXGEGZo z?G9qX4=_)1+CgjqirrY&j_M_m{~}cPzL7B;{JB%ekS>0};E8^{-{M4#j5IOC&T`AB z%MAq))u=U4`r4Cbmo89l;WLb85}eUTr&tXZF2JqYEpXe4-~52daXicQXBR04xgsy2 zA&I9G?=!XqaDrelD15y?Ic>L_D&wEqc6j)NL=!tmqW>+8Xdr9s^jJ8B$1J3zxHZZ_vNm%Z?*j({v^2TyvTaGUO$~R z3R765H=?2;_3_A_f%N*BpNsee<*qCW)b-uQYqcK^IwVBOd zC#6Cdd9bRE7LJQQ33V?rv|@md6Ec|oM*bDGsG>lscj&+YZi(%!eI4|KbJo?N zBzbwIP5Ta`HG|FqYP!Pf5JdvKC_fz(jP~*mhif4&wRqj5B`RF7PT|IOQdr)!3fd)l^!jby~oZxn3QH^8P|7d$<<`l zjU*K2Rsl`+3Jd{m71;t+Qso%QV2MK-2468PlLYH%1(_{o8zM%5gTx}KGK?hgueiM# zSy@l7ifsQf1sVVatA@f1H;*!PaljjdoUQwkYNpW(V3ohHv}Bi8jn~bZRLjty!GYYa zx5H2#E>`MJ1WoOp^=BN~tTp_N@@r2xnI(K4j<{|@9*na&+W-)y4f;VYsFp~8aXPYJ zSH2WSs1OmkD5e(@R`HGG4;u5xipo0anlJjkT{>{aU`>njHT}8N0KEdlzh;hQQe<#O6h`;88PYLba>wWgQ zZG2v2hv__jNM`E4fjEzF)sEtuXQpvoF_*O`zOi;*dRKGv#_);;%&)-wn_?$)E0M zB%M^W4!>_Hf;vHoc(AT{KmP4o0tPJt;baM&TfEQXWJV4n^{LKi2vUa%4zL~&%g-S zQPB5qB;ia2LWl%SS+b_afNCcD^|3n8KrLS3QD$Of^xo`xng4DO7Mqj`N?fWh!-`Jo z!u-{%eH|Wc_iY>|2*7yK6%*4bF9QZPj}8BrSjz2d^U(nVzp*W z_Om=O1>B+Ohm3iATUC??cGo`KCY!|&d(ro!;0gcZf}JRdzz4qjH`lYk1U`@Mqnj)8 z9|VE{svU2HD{3JZ_l@rsej98pt`j#OpOo`Jf8Yzw4qunDW4hKCZP(Ks3hD^CZC%hQ zCog6O?sV_ZGc!^T*xziN9Ab!~h)Eq>|5`cl39e>0B>T2-L4L=ho7)_5j8_t@k=hDI z4|}|%>nJFbhsQ*GY|CvgZ!=U;Hf7Cdc->=Mtn)ib|1hJu>d-P{;<0GYS|PO)>x2f{ zIAbJum~uetErV(ipmUAR5~gaB2fJD0yv(N&PO(Oej=PPoPT%xQJgKrSzE}9l z16rH_8XH^&9+tc0QC67Z!^6WXY&zfjJ$%zT@`G>uT5ZDSmMa~W6K*Ag-;LUk`!GFV z*g7w3ea=5sUqk?yR;RNBHT4~BXDJLx5>a2zw>mEK3Zz ztS-qnqYZ1_jDf*mnp~Hj#IL8KYkpXnlUWp@u$I>9!ek;@-}u=K6Ikt%+#iE2goXn> zD`Y(i8ru}qFTIBc2VC`F$TAzwgxAhRDwgi4>2_{VG|GQmc(^>s@G58aFY}yvkf7S# z!^3l+>ct#bQj6uiS1(ggO2!QEB(l) zBS<+K*N$jm(+4@H<$Nd$W1|hz!miz_`oa|Q~h1&88}DFbPbjc>Ew&0B?TMzC!-AMruE9T zIf<(e^jCW9`TQD(okfAS<~Q4vgijbafw(YAD-By<%>}JOy@~TTu@HR7u3$P-g0jN9 zXbDTQ^3dGzkYhUuRVER!<>3_^pPDIYu{dvGe#)QT>}yFEqFzhazIee2#d~hc=O~NN zh>a-+T~NdY9z};R)AZw27**Xr!iuy{t2f1;@809+8`QJ#_Ty!k#aM63)i=~LCbicd zwL8qXqV-r`Ti7643^?`;8FmD^!j8W9t@j9P=(#bTn%ZF(#$3~po!glWjSfWp>ILQ2XK z`X%=F%{^+}plQPrpx4guqtb7B0>tv9-dCGNA*}vyO9!Ao?6JW7vH5J7bKEVbYwMr@ z|KGdHOyd{!%;aRjr#T&fq4%`vT`->I(zocOtE;QvE4lbfb8X%D$xk;IsTcRDG_l;` z?Q#}9f{(10cTrVAm&u3k|Fi&vX*hG{t{K%I#iU`OcZX1$k8YIJ`t2&))f(2vCgZ?z zG*Q1Ql`GMtq&32gq+v0Z<4LHfbe&3Gixe^ACsG~ zNpkhIH2SUmvg|Ng+r1^7lJ4{b(XTW1p3&5d=*mdS%uDjU%~>N&5>Mcwt2=*#bh)c^ zP?qs<=A~g^R$W0=lT)+jLiW&U|5V;rUlK}INmT3iF79_m?7oS&ME2W|W_?;}E0!;{1GHN#9AlkNr3FK@nEO*|&2jj`& zI{SeGC=6png&#nNLI(xbo|IcR+b*1osfsDrfA^>0Y_{$j;3%mzYWIxt)|?A&|1AdT z_{Dz{n&N_$$i4f1{$$)_$w2(2F6wtFtGpWA9-hjTq?~^D67p7Sm%`t<7)9Hhfhhg9 z8yoT@Il=)+Hc%pLlD!)urfz@Sb;O8qd}K9mtzcZ(%*(=_dZj~~Hx{K7A;$7$7P>O) z)8@bp9tUYT>eFUcj0JY!I4tOU=A)xHwB)$;s0cbJ5KIdmClEEnpaMxzf#NmfMC;e| z;zLPj-F11(`)nc{3gOAR&-yPSMDF{|{W>yBNfubfB_($x>FqD8RA2hwLaRvw-?~dQ zNXh;J4-+&Zpvs6ZRIu^z5J7Ip{w`!5sadUx>~-e0@S)>Y!~6s$kk2@v@8rrV+alWn z$SwlX^&)`DCCEgMEB} z25;{0YwLqto@t6J0yN1m(l_`jq~>C*hQCZi<;yqG9}0|l6&ft@VZ#ca8PGH*r*g#` zWBO%I=#WwCjM>ALr_U7dEbw7(qQ^=s@_6U~SZZ5bI+m-5LlW%|6v?J5 zv8)1C`F8@>^WAr0UPn}RO;aqyAG@solgtL7G3qM1yWfX8@%i!vh;nMLt1^mBj2jAx zJr4CT9-$lvGcldqw7JL|hAq~3u+7O?TOSRl&{Vet2SXQ3RP3KvA03^ITTc*}#zJl8 z)OD*H7?i%@NGf$64Q%Pet04onLyRE;Q@i6)whh07yozdF8}*t$iLcb97jG|lICHZu zI%v6uAKfLYKhIKXMC8H1e|sN+sP(UCPPN*a%42<(%hroXgawH?+;F#954L8Y8D2lW zy`i59rsESpPJ?U<1cnGyo~T-ueh9rnwIAe_ke)BId}!b5@5_Mrh=F$TiL+WRe4iR} zds99R3c1I7?{8|q3rz0alM11~7-#wv55jbiXZ!C4@x>!bz`89nRoCjiQfM8@rL1m2 zj=t3tpfxK;O`I*b%D%^*tr4Bu=YOMG-rGTg{s=N*3s!qqb2FuyGerc8V;~Dp`CQL@ zvTSv070Fq}* zV^0ZtrVsbavpT?rkZcc{EU{FbQ!$Y!+PHuLLDRD^Ft9K<3FehsAGfaqbAvmvA1NBp zk|riSD>LOAKJO4pm*Ug}k+(;HzP(VZ+b6)Mj18I<0fK}6Fs`f7QGj3>z#Jn>$qhFV zdG_#4RR(c+l%U40$~%JtgX@~PtHdo8H^sg37LWe*+LDe>^@mlqCrveUt%=&l*?PHW z6qF#0WL>j$0pTVe6rGtG-hfM~pwa$>1L74l&jcKzP&vRCP!Nd`A`OK*j_^f}Gm4;Z zmFlL4N%Nu=yySEgZoRSlef5apdU&w5W}!AJ#ig)agriQXHzAThii+_LAxsvz2c!b6 zrc!G<{_EW3(=DiD1J`g7EZSo2>#)<>Ia|4HrXwG_b}$=4lmhyKc+PX$4`o?Pw=XS! z8dSV16()~>oDw(v8Bs70=_j1{C;~Qw7DjcFn0=0sVa_C{J`7m>1@f*+N(Kf7Mn+_z z9|CQbA*(TLz_YKs1Z@3)&u@Ps2fu20;iq~@`VT#ZqtCz}S%sRaU_wXjb7pRGG0~CB z+Y{UG%VI%fP=jbLkMnHh^9Q0z6hc!r6@RaPl{M&O{20-^I6xWNcM)*eQysR(Y{L=D zLAx~4qSH*M)mHia7#=AO>$pJkeQ23FI0_l}2@w!AIGOcfR%)E88|EzXb1>3W?|LHr zJPz4q8ULge`hjY3DaiC~r%ht=r=jV=$XdQ>*gmNviKu~9X=@wBB*8KP70A+RXNcgK z$T>Whg92KJY!uzUy&eQTTNQQ^E+Nm?ojmvc%P&MorrS%u?~(oteef{1f0lA9B2qfp z<)tIV+xrzucL*+pvPzj!SItVIM|n8REb_0q0+9E;=C zT{&0^n(9bVSPDvP(>w_&P79F}U?XQ{9_KFtRo~vWZ1`Ee-tnDow5Tq)0YgpU%S$Q4 z2_d%RJvdC`Ji$3s*=LRc0OSrh{X=>R$1FyNhfRi3bLcT~@o>aF$Jf4l-QZMP0);C-7dZVBbcT>4|<5_RcU7N7Q9(Mk~Ml3&Y<=TssGCsHAJ53Me}vX z2;I^OewZ957zAcs+8<`)wG^H<``ps{TNYw0Jb$`DPG0c39`(Yec(neJFU+hnethgC z`;vO#F^n{)K<`LX=6zfbZE9{HP>LS)uu>wkCq@BD&sP1KDdPCD$lbqQ(w4&UPpRR~dLQlV{wsTtL%O<#CGUg3^*;Z%URhK3SOC-cK*zxz*jU&%Be&$#yp#`Hmh_<8lLg1;97maZIR;|@FiOa^(XWOo zLRXys+q+Bbr0@P9_XgkBKc<@mnI($LtOM`t7$Mqp0^5z z1?Cej02J@Ty%nO5tlgcC!GiW!&iRlz8+Fp2lyF4kDf6_I@ z>jdQYiSt7}KPenw69RM`9O`LM zuz{zixz}W_UM4XG$zk@(%|PJG8*orivAnjrpC$JzKHptbEbG{QKX5%PtgR;twj})Q zu;Z~ZLsjHWoFM(Iy(B~dEp7){tW2$(HZ9X)coGN$8?N^V&uNr}&o%8`q<u%tMSw;Ev7Wfg^yjpn?FRjG~lwe}gOd3ID~*_vFB(2d5$m5{Hphm{V;tZI|fb=dAp9c+S^xj$z zO*&OH_>iQwRIIFkYuRRg`^ti=1W)yFsk>|R{fymIEuZK$^Mo4wieh;t5;Zu=$mj&T zf@&|1;+#N98Pf0lVaw2IaQZU7dyJ>*#L`+O)VbxY)9N&rfRzP|sh)NG!X#>-+=J!+ zzY+jiNE~)#EipQL)mGQ424O94X+?HMD>&hNLs` z;wG3;MoI+WBrD`x8u3#BX|C9qgiuny>Q~Mbs(?Vr*(&`EWDOqMcX&oQA~^N=tDPb^ z-tX`B(~a^P8X6WoHU$8Cvi+vEYr)Wiblxcn+HGZi?S7882&HP&<@6R?M`}Kpl{Z`3sAQZR|Pg zE~qO2aUFct%dSf;iaETF-iirid~TT~72J%fiWnuuX#`n)%zE{QlXF{$Ng19gC$y4fMeyx;eXws-0dKEcYc$|H#x#-f9qx?K;uhX(*IF@(~m5cVj zjD@mDp5N8ZX^b8Z7XcpcZ=agh^@=qSw;o-^ks{V&ZpFRK% z`{nt%a%%;Kq&pexs^N4Py=Ib0&fo=R-T>SA|Hs~2hE>&d|Nf|;gd!zKDBax+vfW5X zBb_2CAfPk?-bjNWB@Ke4Y>@5{C8SF_1?dv$K4ag{d4BKy|JUa_*Wr~HviDkR&N06u z)|}&$t5+t~(>pJwpcQlCJ04`Ok@yg_on2*c%z}YYQS{uY+ z=V`K?H!v7sZ#3Bn=^Uogx9iYx83QBurTp2y|G>zkLQgXMykQAyx&80IXgdGfqyGQQ zKMMz#qi_o|YK=*iNY-9cVpK}cIXLO%{ zcZS9!0?9QEu)uiU=tfjmMG#HsDbf>EXHp*>@KVN}`Q^*Ya zOG?nBsKYOK%cH0Ah80;PjT7J@zG5tB1{UK7X2T5Z3(s^Xq$IX=`+Ha&mk;Srj|*0Bn8= zQrK3N5fc|%&Nt%1zky0lt8p1fOY$tJUNrQufbUys8jh#T@ z@_||lGXn##*zEyc6wuh(+Y4mD&pfQ=+V6#)?B zKW_oMNCCKDIMWRb^L4xZs$LMyu1Kx5_Ap%0&&8w z&yeHAK7c9#y;Ss0m>D(7Eh@VFK#L2O|KF0z{v9`Dq4ZKM;I91;!K93OSwO^%o%=FA|k@(^oX2cqoaceFCJc@#19tVc<<1X=C8p zMW5CIDUve2OVF}A0opxt7*wJFvKXKOr;jq&8JL-g)nSo2pJ(}!vRbU9z^I`yn!wfs zq}Dj_-tUQ70d}=&+I14@j&;tfe*vXQ`h4#*Ec_K*;H5S0v{8z?tO)><35Z_xV7|V` z#wjXRD;dBRHgOr&0-H2$eC+`^z{l>}j#t2m)&pwP_=_7f;)aI!w|(<~&IeSi3m{Db zmO50b1V#ak<2w2k1l^~-_J?(-0UY?_JuR>x!G5-8UA7&4;L2c?t|NUpHl7k`*v5vi zBi-ybVCc~E1P>VYOq9>TW{^o)s_xyp8w>ntos7f4I2 zuI7rD7w3IlMjy=LA4JXq+e#8Z+-Q84+K8JN@a}nFF2=^j&`S!YE~=I>cZhsuYH{Ba zZ6BZ*0Chv-eSv9qY+{@Nqd#5iB0h}qGa5J$tQG?YLe=tUY&=jDx#Dd*q8S59rT)d% zzkK-@wy>-0?|E(2C4KlEseTF_=Rk_cz-w4I{rCCHDFsB4gv@4`E%80Cp1KSLoLk z+rF2SwFvmbS@(r^;B9~{S`Fg_#AB0scPAX-wr;SRBF)_1{6Ij{Pn079a5Gez9?%rq zjh6zD&3S7|^z}*$V5WcuW_d0FCIuSB4EI)`z1e(&x*LEI9UZI&l^fyY{D|e{r6uq? z0RC!X%uGp10ls)W*ddTy;a0d@A7IAX0iY^CzuBQ?k#yYwn8$7qjV>-O>Rl@Uf*#)b z99(FtezzC85HLRe&@Xyp7``@wO7t&$Wl*UU?4+mXilQPew>1XT3{K-0C70Kd!QwlxJy zH+6xtW_k;htjM6M2#hY{E%;6@AoGBdg{-7`ZHe}DD#N1gGc(GY6Pq zRwsr>ly*QcfdB)*;Anuh^&gFDkbgjdfjKw;z%94-N43)ecufsW&7r};8Q}QsdNetz z036S|{hr$%05XAY26(^JqIKbEXH8?{84wG&GxDUJg4iU$0lvL0b@yUQugnsF^Qpn4 z+{P(sX%`~~4&;FYAU2@cs-WNiWCo(2CumYSEP{T`i{11~JJ?kXt_uihXs9+YV#%eL z%H9Jktq{Hp7GUmkCNc#0GRD}WLgR*sRc9f9m;qf7P~E)BF~Rl5+u-+5PHCVUjh7dv za^ZJjkRs4JR=3^g1(O1#s*{6FO5=B@07(W9IR<#=rrB9=S#V=$QX0%6l_0Qc)jzdG zQv14I{#5%p@JX()CVutbpN#tnXmbW4D=;BXmErNXWT5#^CoBgtiZ%oixQ*lE<0EIk zuUxxM1)$=AOj%&zt;{r>8o5o!rKTz)HFK1X0o@1lTMV#3n4QDTNjI1)=e64^1Gi;y z$b#?|VFvYTomXMz5pv-8Bpm0y{(0pCfg5l{?|2s#t;6=YGa#_`9V&mrYVU?CxX*_$ zQwYB>Z18khpMwyElv5vk4;onL0@1;5hmy=d{CjA?DfFQOQY5{o=rPc?%N5@;r@4Py z*a4uj6U+zT=+;4MONQ!tC!{Zr`v6$C-b!XN1%n7y33oIIv4phW=%u*P&yZQr0_YF9 z3BE&LR7)MmrEFfMe>(SIA-$;IX@iL%LjMw6*I@^9dP*zM>dkdG^E*H&SXt3%#T=DX z3W`hxB=`lVydSVmW?{!cBM`Zp>g^O26@7estZ}^t4$Ugw=-&o8%^lJMe*+p0vy=`F zNJ(B^B<%3&@+2VZyuq1RrFrD|6Kd-ixeNni`_I_3NC?KijKOBt=%R)LeY#qR6Lx|c z6F?jO0+Q7S{1@Q-G@x%SU`f3s(jYc~puGmfIKz1bvJ-?R5L-XlcxMl5 z|M!n5pmOR1QBkq_5+0J%q;V>Vryc@G5Lj$lh_u!ZPYY3K*d`c}_O=DIsA~x!-mYGV z038J9I^#NNKfmR`%tFM9| z1TV;U2B)ZR-{tuTiVxhYskdzW{AGqUFBxYQAibIKy*!5`B0DDsy#Tv=dMcP!!MwT` zUuqTV4==uil*SFZENgeCuf5gWd#L`c^%nx*uWf>(hWr+tB-@P|*&7K*TU9!%Q@utQGWs%CUjSH5=%@0C%R8^epdN79XzC zExCUC{#1buH*m;eWB0(Vuw>^bCqF{M!UOq)5a+RqiVwtqAV@|{vnDGwvIXM+WEV*# zlLRa;Liy`Xr03@!V?7}|31Ru^0(s8iMp>WK!Pqk^C$#F%fV)Nq9%yM^!$%+{b>l>+;Rp|J@5Pg0g{U%p;)B1m?0jYXu+y zVaXB=07#m`Q+`OGZrXT5|I0rQOd)#`o^t$t?Y4yJJw32)5QOi2dJxgWR6pwxI)fnO z1;oDrcw}hKE%G;{dKI=+kO)BN;o9tnr7a|n1fPP&J_E=SBBEt*IGjecKc8Pdhwp}$ z#>}8)psnbEtD5ym=C{Hpgc7Kfuy7UF!mmq_+6 zP(tAGl6_9h%E`Ge^}2d&`M#Jliv$gA=W8IBLkntVIxxDVBzQ3Mqn^!Wno31;&cJlZDo%o$iFEwr?&z7YDomzSwHBw}B8oot+p;B9f{;EjH16?4?Hl3`o+v@A6Yjole zGYQy^Y``n|^5?^}a5Ube$p->bcYyc;!&n%SDX4mqirwJ4K0>|tDN>3pR#Pt(O(2EX zWeuhpTuKd?!H~^Q5(LTfnLz>0Jp_{EIWyb%WA27>-%B6%(^BZrX${Z!YZ+|uZ7?J? zU|nP!x@th22AAYNBM&SusiV2K0POVup0wQ{%7@n_8qEr>PHk_c%8geLyf zuptd(Ct1SdB}o4k*b!vPQ{cz$i8&v&OJ5M4M1y#2z?IQs1rt^eyk0Q1HLzG)Z&tJl zV)Li3%{yM%hd>CnzCp}Yw^h3?1z`I>T=%;6M@^(1pu}Su!Ai>v zj2xe!7GLlA&ZDTIQ(+Wc0%62mrH1=`-Hn^nMrZh!#o&jLDYDq060 zm(*hkqx%;|5WUcMeMcSJ<`)*gzc+!4?Zkqpa=9m&jN&Qayx&7EqLM6B2u=;=gxwRX z;L-i0(0)i`<>@O9Cl%urh+j+)&LO<(Z#tp#DPgUqA*Di=Lcy2f)s8T za>v4eN1UgJ+uY@Z_-Bl;{&$ZN!>RBsklTsw<;9l%&gWRZ{{&+4BHfaCD2U;4QlEe% zwzZ;B=P$sypp!tjb7MdX!`B6UaXW_vdj`}!$YjsaE5Ya7Gr0#Hu|`Xa1W1uE8d7n6 zgya&WNfn40-K8>fGCcy4ULheNfxB1c!u=g&VpCHaK)xZffvg%{ z94##^9c+y60v3K66jH~;TPv?{mpe{vP{?7%aC9H+*guqGNB|9^kN@P(r4s*OUJeN! zNgo0ih-%SsBhcLYcizhCKEwkz#Rq9T1g<2ty&#|9&{Go=JzT4Hy7~L+Pq*qKCH~4= zH_t;fJz-g;gkae zU2anXDSort-1-wo=NCH09u`2f2lqbSOf z(W7lTk)Ig^SzNph3>d=VO;BbCV8N53OFZCyBzIbfb7xU2u5}J|o0Sl^m0J(cooPS5 z`)nMtgY(nFNOFFr*BlR_!b2fscl3ik5@e|Pm!s~Dn3xzylTZ`K@O>c61fNqU?J8ls zzCaBb1{?RCW8>ln1E?4M1dj%~#VRT34buj7pyc6c%vLsvXK-o@LW~a@5%(oeVB`T; zy$UaytVuBlhf#X`($c;V5TJLixnlgTxe+)00;Hx7R_sK+W-ci63vdx29_TXg<@xL- zaKL94U&gU&KEmDs6(UlH{0!2WJepVHuaEvfK!Mhcv$Hd}BKX3wBre@=H*))fGX2B8 z2M9u@0pA!w&Y!a~n2L0Uzl@}ozJQ9L_uhaUOtvp%6R_~XWjcUL1KoTUAom@qoEWX> zybl755*S`6TtU=0s&B*r5i|HPs0FV;!5Df30&hMemaUf2v_p}_rYnwJ*sLWO#vV#! z>kzj=9jXC>Yt@QJ-&?l_<fLc?^S_(SKg8Lfj zyK(zIm6-DiR4s`cY2=`e1k)>J`W3?#3%{CXaA*kani1JOU=;vp7kIVQ4I>MU){8;x zpj@0N>E&uoU<60dA?MZ9&_L%*X58%lM^MzZ>PgZ9wL>rHp>p_?vDan=6esib%9daz zq15H8CpbN{2hpdMxjBbPV|{<&!TRX-6IE8I0YJ_L)}?7p-~}cEtQUx*gYW75Qez$(K`xlyKoA@&mcXZ321&f%mjK$!NGw7ru2JcVG;KL@~`@>LZgmUUn z&}0*^d56ZQ!B?rFY6Jr2*&{fLE+a@uT|lN+@c~wJ@|1pouV_y;gr)Pj!V0^ z#6Pje8xEq^CAuyH-21hWLUC9ma8KetuQ_)R=9b-?g3wHTLxhn<>)+8f?yq@>X2{bF z(%nS^zv&RXSW$syYpB-NR&Q8P0TV3$^z!6!CEowf>og1z@&9?nHr3jgdhOpAO|h0^ zU-{|!p{FGJhhXu)q;;g|e}U}k{{^x?i2W~+{r~;hzoYdaR=lu9b#(s$jboz&7MJBe z4<3x}%NyMygr+*ds2`7jLXOc&f}{v#4X)xbm98cdt(cIvN#Kcu+v3vM(SzN)vY>Ncy1bT#MnsuBfOAbw` znvzUQs;pY-);t$fNEBIHv{_>l6Z2yg6%;}?EC|)JN#P>v60&gnio7J61bXnH`ChKP z7V;ikWmVNfcL}(&Rp$x2O8)wX)v=gYuSB*lxYnhrEFZIc<_OLkyhex>wSS0)uqzV( z&*ypbdO3FJ>AsAvL)(M?EhQsa*yBJZ*<+IZu)*?J5Tto(?iFo{{M^;7qI7y zO|btzhbayl^J=cIE9@_5Ux3$=(0R(TEVXsLIhzhnNJ`wVn%s020}s$UR6vudcpJ=a zWR@6|gH0JM!aO6vFIc?&quAS)?H^I}8lBLemEZeTb(V#tC0ll9JU8_YJAI}iGx}4< zVQ5>@{vTU1QcHa?AD@=i+a3IQkd>cb6Ps|njScz4CNS9VhZr@Q?mmdNYf|(=D8v3d zq(9ZVzV9hZR-6d3VckLW-GXEJ`-E)_Ss{NPJPM{a{zbWm(0`LJRD~cz%aYy8V z{|NdiiYE7vHe>k_s=tAI4z>FapIs*F-%%O*`0f9Cqr>yQMn=IOMVE2s?D+|Q(OeGZO2otW63;~Y6hD~-th>w>tBHuioz2k*y>10Z+_U{t}9rXS= z(+o}28&Yu=s^0Y$zioMT(H>QJ8nGdjP;)82RDzPIg5H>zhANXw9hV%*ce}c;&iZAO z^0Uh7|49zJcIDaf{mbpi9?8?IH}DIdk+ZSG$qBs@0;I+d_xKM_9({N`P@q*g{P}5t ziE4rRCh{yHH0wcjsG`e!dpFAeXRv_K`OI7C#mk+-s7n26xAwIe-43aTOUL7zxo2;9 zZlz+4Z+0&JP;tB;dAJ?2@6#?f_KB)-Y=Ul)!XP_&Kh{=$A|!Z>AsYM3paLr?oo>2z zcTnlOY4S!%^2YL%2anSFY~ObvaXIK@LluIn?@k6Z+E#x|2uaLhP}Vg5QsPr~7$kVXIr*-^ zS4e18o~J|S?gcJ;lET{q9Qv`>o* z_P5wXD0RZ~ldOpu5M)6smTV*DJd2ZQQM*GK;qTf@@fSVzt940RI?abng+g>$HL+#< zI-Xk+ApP>y7ODxlyOC0~S93aX5`2GqYA<<)pVcd>$bI_F$Eg{;#!!ul=T8(w-o-#- z^Ru@mF%=p&Rv6swHawp68#bsq_lOmsNd&)#dCxh~~=IEZPhVI3{cM_fXrcT5*!qmxtVS(O@WW{)%6Nzr3_D*m(2LCY<7eTDt_hSZ#* z+qz(&x)K&0oi@w&OylA+LL{2z3fV9@` z&L=*a#|;Qh!{H4gaUkXjthEw zEA6x;=cGUr@<7Kr1#D8R@4k#qEL51hWpT&qJn~@pN+hBDmO|G$1^k*=T|`W&B-8jW z`=i8mitrb^DpeFJS1GRE#Gw1Owm2jZwRzBcv^Cm+H}d7!n_7XA^@OG_092>UVnwnt0=m1>2m=0~ zZy_P|eiAK6lCoFa+Y|A}<95r1sVP;{bj0%^iVkl*R~64t5mFkK2Z1Ev7*r&#FVG*`kXDP_|k%TpnpHvD=l1aB3 zspl*8eK?*@zRyi|s&cWWD*KMGbmA?8fb+SmaQ4{R;^b={-DRVf@ z+dNz~{v}I>dUw3;{2|XV;oa=bk!`o@GqtdEJOUvi0FU%NqZAglvC5Os8 z>6qwPWsBP*6BF<7$K1S&Wn~&+lK*u78|#<8-D8f~J<$V+$c%0(Wg=o$MimAxW_{d* zkQHP>bBFSpU!oo>yXc<6Uz`!X$f4idrH#izu5+mo24c|!*=6fr1Q>llzR%>UsE!Xj zbb7geYo)(%^J$cs^-vmf`Bh%RAE+DE(aEzI!h}g=NZ^FU%ao9yRa_<7_NWB*wj11@ z6!M7@+4LHfw14H$#5C>ld%XVBtMaZ{lAT)v+hm0#F|qU3FHDzmHe7B<`uXF!EAZ&u z!fIjmw%|?w{4o1twDBL?;?|g34rv7C>Cc$${u-vPZ5vJ|66LaU)!W@PtVR7wP4t)v zp*$#3=wcM=xLHiY_2ZBM$5I1@iC6j|f`}oOrkMka5RdUk(w^x)^$m7)EaJ1 z1?jho3A0^Fr<(_dmlE}2{B^Syab2xqyc@(Ay|HmM3apZiUxQwa)StI@mUD0?`WVqy zw#|`ucFvT!u0U++W8JQ3r;Q{KI-6!hH)sx+p%y+IYjDxt6SDf_Fc$QuH_|!DAB$Q-lK27>TuRxHQ!$S>-LHkyTNq$@^-3` zzoP6snVkR;pY3<%_9kD^fhD~<>7^#==eCEoyh19W6(h-+Z)~*Qqx=Fxeuwz067egL z2CQu=`Q2fcMFi`L*O?vlDIHH)DZKKD@X8$Bv860LaM-XOc`d7b-)>rNbwfKsv6JG9w(68(~=7!v66wS~!A;M1dev_@eN|jjeQe5h?t3|c0 zuHNjI8~kmB<1yI`UXoNPHF6C8@3VAMp*>C10wJ-%LwtGtC>nV}JbW&8lFoC)wWqjd zjHtrcjNW192fszxJ}`bpa|=SL{^cU2Wq<7 zR$2vq?W_X&in@s=?q7NDV20<9F}xgO;d*3$G9)xNjr;J$OR1NSllbkcN6WN+dcO5X zx=4!YKHI$+lUt~K>0|PB?-a`mkEp_QcA7knflg#%v4t4X8oMt-NPaBplkZzGG!>sZ zx%)Dn3CnLaIwwJpb>z&+d?V89rdh_-PB!S9IiG7rN_V7VsX@mvOUxjuo4`WXjJQr8 zoS69SrL^ro@pIPu^A2zEk{)UK7)Sh^nQ6E*PH%b`7y9EIW6oKZ|0Lp`oAmjwZ#IvG zh}e_OHPya8K|KO^G)B1X*Q1%O*Q0_5g`wijpzs3}+I2m#Dy6 z73=95vE3>>+<5MEI3t^FW9U@Q?4#Y&zIQKaS50Xm`jf}W`eXaYZXK96>dgF|%*OtN zu$2!Fk%f~N#OPtJ&t`-Qy&A*DB&0I6kjzoC{IH|K7bkkK-qPc>>3}b#F>_(EJ@R%w zw1dG+(O?L%S5hif)%W_cuH0x`>~vSYgorsbafm|-@zxkO4S_W8aZ@AFeshN>ks&!p zcsstkrTg8Mk4|grgyQItzx6jIjo^E7r85^kIuBSoIuDT+>6k@kn0pTBX5on0SgQElO-7U!2dipGp| zJ)V>=AFl`;?ap4jn7gXh&R3d#=~iQO8bR2~LvEL(L0qEZD)z(>C9g=LU@^A(-l>F- z>w}pYQYk#VH8a40(U5{Skg76d;1)?2aH<%t`!|Ee^u^l=tTEZ&wf5P=z5Kt z|5)YpY@j-}ueoVyy>>Ay`;{OD}$E%;?1k=BXpF;*C9%@-qXq%&QwNc)KdG zl!$Vk65z205Ga-qC?SeDtSxX4_M-$-Be6gpRa*6gfUHot(ph##b6n>_+yZ<|@+o#8@ zlOZ?@smCP4)Dl)L@+*{>mlqM)-KjQbGVY`c4D2G$Y z`M-U^VP2$$dngIcQLDz9-~GQ{clZbog>a;)ev05w+57fh&`57=qqgV8QG-)08}*mY zn(mi7mB^qTi+Gh}=q;uH&Txe-P^10tLWI{7EHeE^+>#^3ehSiC-8ZdCe&~Q?X)^`2 zx#BYF>espRu*!T(lI--uWN(fZka^+oQ8wE^6fwZAU+?#pZ2f4FIYfkCTS63f@OKz-(G(sS~(?hz9XZo)_kM+VMQmh2xs z9{a!Bx9x^<&xc);RggLP{sbeX>A!T!0;w?BExUhQe~d-zAM12D?J_euY))Fs)XC7v z7gEW--g>Y`F;>HTS*91k$nLFJqNb{(6Qd7~rW&E}%>0=7z4^{x)D?H6R7^uoxxzd#`7`$$s$+dv z_oS8V*f?i8$nS5?RI&M-+#LNZ-Cniq^jVbDOTsW3^Z2HqWye3?kCXl`@)Eu(?${Mb zr(R1X_7m{p;MWOWEMV{K>SEMUEN&g|`&ga-!F}oQHrbVeA$K|bgV5>37N&G-`+2_x z>wvh0JK@Xa2g}4GDtIHt@1v1L-2El4am@O|RT_(m{z zPTE^K3jZGJJV(tv>5fgW;_uymI8n0BGe?Y}V#&Gqaz*zbmD}fJxAMCXZbGvQu>vkF z7I_w9b;{+&q@`}3?R<(W9iOrm%dBQ-AvdyM_Des9Td?KGb9RmBoI97yI*K0Nzjw>p zzZ-LVoOhiY?zSTRt>dI}KeI@sEdxs!9gqoeUVu3Pe zr_v@!a^eTpM8<1EE31bA+8Z}7(rKdo{$3NP*8BKy^Ff5Fyn6LyV4{GG#}(d}HXBZn zA@P&&#-r^~bgk?reR(e>3>i^`YTqVYwe`fyZU~>Nji{;0GRWhQGB6P1Xe0fy+N7F3t26nyhp}f7 ztRByVG;K||Gic(}@@027=nNNagf?7uPE7K@@{vwkEVoe%QQMMJ@*Ur1MoH3!f7Lm~ zuasA=l-t*#!AK#r^e6aT|xzJOJoQj6H_sG!k_q?h4KYh-ddtC~0219zL zELK*#vurdv=jz?lXGI)2mcN#uuE~`})=84F-D z@Q&TCyO4!Bg^irnqZ|)`>F&;_k@1rE|DGtNn`kPMX%Hibn-$BnZg3wYcj%{nH)o>b zE~wT?%a{;Ej)f5svdeb&CchxqDXO$FM6^ObGsvIr)_QCek6bVwPfEWdJUyJL_a^a z&G>!^4;vV}Y4j%eTOi^N>X=pKXLm?&Y`%HQ_c4!_i@w`zD(;r)OWuP@b_%`Mb${<_ zJmvgaJvSP@Z}V->lAkbYc+}0)SB5s$j8y@bu4k#d%XmL^blCZhp~T(jr~H#4dL*lf zQafHU!F29iyxQC24yXK8_xFp*i-okTlr@^)tI)pfip62n#io6`iG}j3Nf+E6qr`SD zFc5q+BynY7ky_}BYL~q8n0)_WL(sue^=y5Hog!B_SF_~kfeC=3ag%dP7M-Qyqobc-K{aIB_X z+NO5<$H3PC=iaV(Dc|te+V^|{g8%69vRYawOEB}P{>*zQox;YG;NS1xAK=`06x*P@ zp81&VI`d!hz)xD5&(us#to89r)D)u}n-0>>7pi2(vsjG7>2ZmrnMgY&j(Uc6YL^!T zBeLr%_*90>J3k?MVqg4P8(j59pu92p>sYiO%e=XSK{(xIfUSZ9438>E=5| zOx5`W%bSxUblnvvIkwd=M42&q{_11l_J%`?0K&KUdH22_tvQ60Ti-%in<;Nj#{W8h z`Rqf*;wQRjL`sJnk-}FTIoBoOAfV_zLpE!a%Fc6);d5gqwtYJfnv7MIY3IYxJuS<%`0eWT z(u`-l?Bpcane`gVNV}xF#cIoX`lG&eh3a|;7Hc|9CZ{je@yp|-FTPWy7JY<+b}gY} z4BCVGS7R|_*kbO6-J1Gz=k!dJJ5qVf?T0O97ER1JLnm`4@ot{(`wA_6U9DPEZYlNR z>z@R<=Ubw2x)Io|irD!$gIMBeUO&fk1QVH3pZA9rm1#Itxv8dCF)aOw!iY*|`Q@(C zJ9hcOV^L3=P5(Rh8j*Cie{7;HbInVk^OrxQY*L+DSt_+(;S~Mwn4eO!dY8{K&>ZGA zUewfWt<~drnmtU;g0%K#Bl1&jKCIfaQ!G2anbF~A6*f1=VH@M7?ksp|8W5qx7_;!s zX_mprSIfAJ)b_OvwMJ_Y&)n{Odkf0?+wCCb!STHmW&ZbO849%iOpD%}oj+M9pYhoQ zOp^Jb2quy(vX+xQq#o*r1zbOzXJRZD>+sBWm-ICFlu63}m^Q-POK7V{AdZcNBiO&f zFc*8#9TA1ycaEuUotJBFS-LuyZ-aDZ8Ku`_iMdlkV4j4acWT)rM2NUdb7gazt{@zX zWtEBrOUr2v=}B8%_1WnZ%uinXuATI5qSU`YLiqtZO1#sM^4UH#P*mXVd5Qy z44e#J@ibqKgPcl63-1wI!YKMSk7ty;E`cV+JW5ruz1ySiI%g}^UiRhqN8zFg9GTG5hfZ>(yV>iAwXqO=ix)jx2=>YkQ@)e{*t5G#D&c)}LjR#FEf7 zaxn7b$zB++8(O5WjSaRkR~nWj(c)?!atC+!zgM$lziM*KvAM(yW!#hf_PhvE z6xK6N!7Q6V?(GiS;-W6bjwdL{Z12g%ExosF9G+%QsQ!3mXrhKzgzQNafnA|sHxeuV zqbXyS>6h8={Tsn~k1`+U`!SjZ3uL~@_{`kG`zr0J`?VZXOIsc%>q|E}G0{Wz+S?&4 z@|rF|uUt-cVD{AUuCesQnkmN|HXJP0-m@90mnvwI9W{SrH?2RMDE3!HF1$xTi)Fn` zLyEO^!v4l1)f3m9K5+$XzVlipJ?vKVq}OI0n6v`Q*hJB&ZAk%sSu)FLjeC+y=gy@3 znqSS$6r#gW2KOhPuI#bv`vnk%%3C8Q63o)5NGD4+SBnO|+0~U6Reh9;N{l0@S&()r zf1%*d{bS5Yet z<$;*N)j3_LEMtL%kF$=csbz$!-yTRg39<8yIpS5T*Q`=v? zeC^XiQDu5dt&&t}SuL3ak86y%v^v<5?)&<5Y?M8pEw;#;!OqST&1tNWDAOLF=0cV-ILlyGbX$z&1otj~hY z(vIXdIE9B+zjU+uqjYBqx=pjwzMxxPP;E{Nve_LAvfKmB3wTo zraYKKtX`}^T&Zie<8P`N;(s@7w{Elcd0X6K&)J)Pmk$(=-vyYn>OF61(a^bF)phT$ zO=-oe!;aK}k?Tgk48%!l9)Bpo-(6@`nEuk`+@{ROe%s0*V7{*W7ou#WT(?rNtEa^7 zH|C+U#P>TM33@SzGI3j`d_I-UCw-NHgZDajsCN{F_}+qK~ z;!;NRJ5@RK4M=={;-^c9qt}1@DAUyEp`#eJ!X3jvC0@MJgTt~BBXwj+t(03=dg_{X zKEd|uzZ2I5qYZ8GbNHP;;dXda{tO=$q-~kHcPfZ46_9TlLMFqZx_0=fpG!y<5tAr7 zC;fTvn#b|5krUbc>`e?V9$T4shtVx6NsC0TSaLfRW}{Aw)qj^->6aV@+v^|qNi0VM zYBu7hBkLutMl(3_ZyH70lnSmsBG6k-=73t%*wfrDwhx7RtTl=?eEAmhos95?Ui@i< z$|XyS0WqnZ11 zhAf1!?>Phyk}_qqF29a=Y!}M2P+Cz?rJV&ZovHtd zXE9}S9rIXzdYVG5vDJ9~UfMnybs`WY24!SNS_K)Yhn&ZfLwg22ii*|~i7JxCilq7L zf?e0iP;3`<;USodN3k!;C@pC?>`?X*@^01ovHV2$O8ps0OB6R`U%2T(P0W1NiPA5y1 zi}%7-x>!-e5k(4wriH_CaXAsSL!I-;F$zjV@Tm0pmI*btrM8g5^|Gf#cbs<;bNF=W zGrQOr{!>HLSLXhrQDI1aFS$~V-I9pT_ux!7om&oX2bjgr|0q>x}cmy`H_Wv=&wQcgnsaU-&nS zjf<66DD>{+s2Sy%_NI#PW(c5O*q`U=woI&!(ob}A9$e|E7rinf_<`a^?y(IAqfPX5 zVD)nC6t!#XS(;^Jg#n&6mXl+t^4N1^Yx$?JyU9t7e7|~=BgV?}Ef?G6A3go_ExR`< zq{=5EK0eb|?n2`XhP#~&=y_M9>FPNp7_LhlbxrbvYKPz1aBL1Ix z%IC3KSj2L8Wqy=4v#s{a85|j_g9MNnOO-BdV;$-fLVb=9mz2-d#}h6$o#hchk*R7 z;d+I+4_RYjp zEi*p`oG4SHs?Yg__ItN($A`MXj0O9+b>&*;opXLBvKse`aF zvpJP+t?;41R0*bhfNF zs~nGj@BKIA1XhdG;y`E~J;vmu#-sspgzOc$lAUpLQY8K9&m{WFiZaRbq4@Zl9AOt5 z{F>}^%~qp!4GZrU-w-%)lyUN0wXn##iJU7{sWT+UIS9qH&wMmGog`l@udBC#SMo9m zn-FFN57B185CTe3=*;1#sp!=lN4_>O}RgH(d)LmHZE?&wqWEw z^suj?VJ0xVw{nH{jqD2F)vq4y5eK@^esd4g)_AVfVOt;l_u{ zzP(@1PCm5x;bRE24LLYW9gkw!K2M&Ym+5-49d1>DyZ*N;(_EuEauay29Y`w18%?=uj^CMI zC4C{296n$A3O{)D7ctEAfmmz5PNE&J(A{v@92(6$&I zt>r6!DZDu>WI#)KxAbQ(D#0y$t+xpmI}`)!-g#T-`%zQT3?0KA46lB-0nK6*S5YCs zn(Iyg+YQY_p7Ra)^0JPgzPm&p@+^EN_TNtjE~58v1;9A@F1H}cy?rwZR<3&T_) zA3E%piJLuN7H6*%3Vchtym%+EHoeB{I)`k|XC-rboa8qT%&*hD;(th_WLe+X8bz&= zKE_Ve>8&_c%A_5u!opqS)jGi}{PU50a#@lpf&jTwFXX=zZb0KRP2O#{^rU>-m4xu= ztg@4D@;%J%G~Te=QyZVo_pV-0@?_V~O7Aaqa`uo~+w+ZwNT@NZP#nG6%~>v!8ru0= zLoFF?;XT0-!^RMarDo*yOMSNC=;+NeUj7G9G0v#pE?ayks{p#VcWR%q zT+ic9AwT`g*m#N1Iru2ZHtf1>vf}V+;!1)b2G6xD;o}1#wpI58n z_w0Qq(O{b@;4oH=_dJ=w^vWw0v-hJ*%vbe{oS2uDH-7){!|uL|^TQkOThQ&EC-LrS ziNdTqms$vhn`-(SMsC{Qb$m>&;a__el)v2gH1i@s)O0Avv*M+<<3@4Zdwr}7-phb8 zjjuu9N^4yae2Jwuv^KB(5ZW&pF)$F=7|rR{tqd>1|D3&lp9mX|pXh3^rJ^lx;^G}&NzTgn)$`O#YgQ)wa0hL%@M>4->aY5bo|P!1IQp0rXazf-|57+K%=!fBJM zE0aEOv^zgYsvltquQRSLWrIVqw7m8W;!l=63u3gY^m=ODAZB?)AIsEGy9|41@^WkQ~e~BuX~t-qeMQ* zeWp!ay@&7phg$DzJ3hZmU%JBbPdCwo5mq`4k0JWPiit@(1=ro9)o;1u zDV{E({ho1JO_BH&hVH=Q#r-%6+xZBS@b9UGS+p{4eYY=v*MD#&Q!2I&>SG2tm!xd2 zl8v~P!fyw>&`z_7T>{hF`BvHjaXKED7!1E-mpSyQ?|I3KS8-*5U|5>|bE{5cg8o-; z_QExPn?JjJ`4f*)okh*Jw{ctPWUo*dlk1L~tv;b#zO6Kp^Q(G+{KvUsoAQS%<;gP> zIe5o3(*%V$qeDzGvEk!Pd{_O}*Wkp|n(k-GMW4f{su}${dWur4#dj6^l&XssnPc}BTiZaRy)Ea8d#oI$Zg<7vg)0oUMz1&O53jR-_>ml$5j3l;Y&%1kUOyC-BIy zR#3L@N~p40aOoK(rY};R!Qq}JIF_E7nMp|h2u_@Ty}vPVRu8{%oAJoa$+7H=WsQ<= zX0AN__UG7Zxn6HJsq$;AERxXE+Q@5Q;H_7DvZHHRw#HukGTAhA4zT-EZ`YDew}-ry z$Lg^Wbjklhp8FxbjY;57(-{WK4yn<*>Fl&*FYieWJH{84S7Z_VgSBevoOX45O8SAu zXY79Q-KB$f7-AcF`~22=S^UQuhPCE$?!Nn+?`K~kfsEv{qRoPINf`;-5YNeCM}Im2B1Wk0 z3^H$|LvZ1a8*a*9uZB=KeAh{ikFC%!1>Rn7Ho9DPSQaLMpb*4V#z5fh=5)NVgaSzs z%7)sN*j=}xeGu9nCz?I2ym;NUA0NUgeS^VIKmnX~xu40QJmE84`YaRS8b8uZ(xVqI zMxj(fPZ)DGZvUdyP$KkKG9I6*6ca^*w^690<~8UCfehmZ@!1+C7#$r{5>2+UR&;$< zv-=MWjxGB5514&uIhO3SXr4ss)DJ02|2hJs(8+bpIFn+4d78ePZ`YE6d@Yct>3rN~ z{SO}lB2#Sud*Pm@T%=Yq%F<>BgofZ&k~uqTKvc;UfWIs=Tr~tTChYC5g)Ar1ag~Lf zfXl`*-D!Y*@ExE9#4}d4*LHS_yv5&mKLB7Y6F{^N$gmlF9j5;8-GRU7KkneM1AnSQ zOw#`BQdqgue~FWqP~w-^2OE<$zEirF+Gz7ikisdsDaje|5nYr8 z8}|+v!*}hH{<}lrzZ~K|K6LdX^e68k5EDKN8y|$i_CTHuy00%`ZTy}}KPT}RAD&TUY2XQQKq6pb4XV9B$1=b7;H5T&fEZCFr$^el{PHn)cqe5iq z>toJo3dT(0&-iEx&TaUhYe^SELaUEFEbNw3HXo1Y!Syg@@Nm6MHmH6u+=zv{muWhi z^wlD~xv+AP#(alYqBt66e$}`mhscmCE_i&PPq!ia|7QWh2XkNrIB}Vs2l-Bx;qRlD zJrp=7$*FTdpoPGqs3YF`_Prm31T;dEHFTR_g*Jb_!vOJcH+f}dBkOSDcy=S?H;miE ziE7GCM*pOITt=e#jrJ36Kwf!8>$ZWuz)R+JR0B{Vh#&|su7aDFcM`}H2jVJe0jWwL z#t@PI6;Pglf?Kc4R~RG-jj`g=F*NKl8;%8tgpL2`5dfV*T@2OZBJlx2R|G72w?GIL zKqagy#xEiwB7pe6fxdpmx_@*9+A0vU0}O;RkHX?&x|EMV8gkpc>tWRy$c^4^MgghK z0Xu@IK_F7s;Igk)rwxr+0OEYE^ZHFd#CByFV^4%_Z3(xEX>}>NIyxea7J^>-^0e|Q z2WC&gO)+{vqoZ;(-Z&csx4$HjHhRl&jH<*1pH|wjaVb<_IJCXfTCA>N)&o;_=*% zD`3oSAY5F==557V$Gf(>_QFIitv$`l=>9LF!Q`2_+Jiiz9cO+$)8<$kF1=hYkMAK=km2BJ@)%In7__7tLYw}+Wcff2rIk{ zYi}fVtsTc4X7R<0f$S_QmPq`pS{4tWhW2)bzCjR=7K^24O_;99zGT4i%A~=pw!#qy zS?p;NAj3+v_TJJ)d70WjrQRpD&TaiJiWD_{1ZmppmJn(>_;_mD>nlirY(hWKMjUw! z?DgIUn7gigZmOS4IZ0Wb(O)IpKjyDJk&8IK`0gjHC}!Rm3FKQa3AQ7R|l7 z%ljCkaC2c#ESfbc$$yKev2ixG@T980MWWMH2Gut7my-l2K@ z2cZ7}x(KkQ?Nqj%cmgikdmyb0=!t7z`mlaLZ7LWU8G+cOCqO-k0xTX7W*#x)h}_gl zEQk-4&fC#3<9XhE|6*me3`l)`09^#=AFUl7J^%~zAL|BW(}i86ZCZ z3=eo?-^b1OSuQhj_V)HbLNRNTZ|#1rN#uVku~G8ElX90e&4DZoO!8}}vtakVs7TQ% zH{y}MMp-uR27Y}WZxt%#rKDDN-PvxC~*KUy{3AAF#B2>4Iq#2U-~nF^31dt2?1w$(b-XDsiFpqYT}#$9DfprD&u zN(G18MD4G8BmG0a?%%w88+f|%vF?987yMx6vlzwGWVF82TKWE85m-3**0_*5#=jXg z^gKSzB3f1HPfJx!tRcbJD68~YvV8C3t>Ajx0Cf~e9K5Yq1vZu%Hwi)z=Ulm6yl>JB zQX|1X)>yx|c=t7Lj5ZOR_syjdmY931oi&EFmaAW&-2bqSx_m#QL-Uy&s&9bb;fL7*5_d}^7?ipTi@ z`vMA7gjA0GS{0*#zn$Rf7U53e%&&NN#IQ~X2RZ?j#wwnxtE;375F7pP0s~C}+2A-O zf#G(uj~Zm&|9Swe4@d^eka=tYP7yb&A^7?KDg#t@UzT;Xa;2>8X zzL^Fr1!ey2cmQ%R0n)pLOnD7+OV}2l&%g2qGwDkM!Oj(6<^8bZ6MjRoNUgo@$RMoJMufB=8~U;zNdMQ zwVKHhF|v|kC2zXN*k?l>+DF_Sjz%=AxET_>cM2|9Plm~@=IBgj$VHNUL#<0e)(-m{ z2!1+K&WRsgT7}moW{}yg8Scn376*tp|6G?|kJ+BbwcE*E#ASb9&Z1W`o6^vBmO+vf z#}iYfO2Nb0OX|Eo(jPze>X_PQ{hDli+9p{TyO2pfC2VhW1?vB_)bG1>kB;My^U5FX z=B~R#7&O&xwpit8hYEXQ@}z*lFIj3I22_yyEXGU12E*y-(4cZnY^tYjZ1!oLnktRs z*dUd^A@g%klh00ja|)4sU)8JNrc#I##Gg=nPt)neb*VZ2Tqy9&bN@uhcGSLXj_E)U zhm32hJ^V|v9qIM2w%0~OC%4kZU;jHhYV+QpenO23N{fAnTk|XQe#gD>f7&n_T2~)tt~!^0ve5^mv8s|&oyWiA`K1-16Q-IProYl+SX`&uF)450br}N;dx)# z&l3n(vufA&3zanduv^`E|8k28$pEy9K{BuM!`2-e3%Zpk-^;v_kr7Gg8NeP%Xf!50 z0j~`ia$Q{&`Hz8GZgPy0gaQ>K>u4a$79~P zEf{C+R_L{y|MyzpvRqzT8dLh(I}L8d9&378=t^Rvlj?K^4yA@K?ve_UfiMp_Z6?>A zFRqTq>A@Bd9F;&aElcJ19{a?>Ct+79yoH>9u5N_f$L3Ex9uv$RXiNjjRMOHi=5o0o zuz5%^bH$eT8tWU@1l`x`t5)vP1`UJDot6f@Jkj}xWQBUHynWi_1;T@flMzbPKe9RZ z$1g-^Qb62NuKd-`iVQ#gxwj=0=gcE|k|zv{?1eHx&m;+>?q{VuNR&4kHefB~V5_?S$T= z8t5T_K9q(zJUl{fe6JQny|0>2GdT-t>8PMxzB^BE-}_vIJ{Y!~tRpKkuOyFsgD0O| z;$rYh+UaTuF?Z8E2Q&=+jO28`+nG~G0sV-V8tp@T>OnI}LO zdoI z`jDWndKvK4rC9fu%`3Zw0Bh@>^W=-Lstg1VW9&P&^9d~12wHvDQ&uwFzZNrz&7i+F zYir2ejwO?KS}AQ+a~qx?WqF#%D~{)%Pwp)RjClElkD9wuxlpIm~3d7AzpItj`^O~1@--Y-~p{pR*ZrS|PkSEiTD(yC>|lGZN$n?au7>HPpfM|y2y@A;Q{un0o(Ha(3DvZ)!W zT@bbS5pG|OIa_Ba&D`I70~?cwRILEq+Ncku7i0-WWSa~|-qhjrgh8NQJDER=?}DEU zK;u(HpYUNWdi~*vELemuELzRjZVEWy&}UY-QB zCzg;*^em97?af^N$yJ%n8JK{D?RVP;2;Mshng%TZxwAW&^#YiP>7EBAfc$1bhj##| zlEC@^fK%>0$b?(HbBYg!3JRmWK^RhI3)RNa`aB%Ydp~~sAO=Lf48a>fmstalBA_i1 zcYJr*Q3w!w4A3%-iqfj8s?t&ui%*A~SVkBGOk({2c9fKql;&<-0a%m07%a_y0fp&5 zBnMy!dw}MfJ|K<&Ce(xEq#=S=&*Kdbn+{K{g}4|NU#{cg7Q}~KVLt&{ao1C{Ce5Bw z0|${H7+PFeH# z8)WERLPfxE`QU0v4zXJGxbJXB1Bvt8>Xblx#%^PNHt8wnOd-^C{_^`H|8lFj^sNOg zfZUjHG@|8$F@l7+im<)!ALqhg1{|zqoLaV`-($W%GtvzNgx4wC>Z7|-)@Dr71Ezle ztC-aZ>q~c<_EQr>8V?}O068x2tX$uWXa%NRxbM7wwR4%V*kk$4ijFCUEg;mV-ZBq{ z9fd2M@JA?b{)!VNU9jb=+KW|{3GIdo3(5?<>*fJOa8?fz8;A=g;Hieg_CD5D>Nhr; z8FdkgaoWB&z*lzzLE6egpmF$+TCb&oQq=AmJQ7F3Pd9OPtJvW?pF$;+X!;yk)}9y2 zY+vrOTu&=jk-^ijpE_bW2hLNvcez zv<>Ug{#dl?o#03Rl07w9Pb9Q)4?|F7^~Zp?`Dgl_z&>H~(=7e4PL>mqSW zZGZdaF(JTi2cAKF0XeB!#_7KX7b7dHqqTJlpnXU5&K~tRr2|g`Yrxer<_%CeBt})|3 zEg<{q$~g*sVJ`EE_u`{2)aOrxU!|i>Q-sj3_8(vniEO~ZQXj{G^1NyAWh_>k#o9UV zUTI)8Oc{kJ498-e`5J?xEEklmu1+p&p*79S`;P6<>*=`v#=)7hk`_#k#6OyiC}^{} z533nM=xXrF1NOe1Li=7zGELc~@147V7thm(!dfGF&21fH{R(=IK4KzFL9Ie&$3Dak zM#(kyhfKM&6OEuk>B4ma%!if9DoY(z5!&K#gUv)~Y(0=kBZAQ~eJCIti5e(q{Xw0wQy4BU;n9#`wu~a z2?<6~5Sb+B`jluvQWV60fLFM`ioJwy0bU}!nI*#&<1!))PS1@dbz^$-OGm7q3sonO z`D`q-)dGaJI7f_&T3?_ChSt?n!N3IK3PR2sLn1V@j;oc|Kd$2=kdCUf*z4E=nPWL}C5GYwr0QC%L6|6NoUr%oZbGXpaBZ#+}vEM#M;qf-G3JoVmI&r_q)l_@WUi#5GPdxoFion z(3StQ<$!*^O@;mPghw9$eE)Yhgq|0PK3M}56TlzKQN6*!!tw(KF-!_wj13G70L8qc z??qbS>%K8ZfR+M&{R`kmadUB%1NS?CPDF$O9INubU-;FURB9QWWW$2E^0%H&t-Ad|}0k(ks>3Q)$zK%~c>J~JWM|zoVbmCH?{`g=rXU7p)qeavpi$Zht&3tmX=@PW-sm`9V_CF3F;#e4oR-D2djnH7l@Z$C(v9+b~Y3KY*4W}%X z(nb!HvbR*xS#gQm+F-HjG-3Tlv|!(vwkn>X9cFR~9n&i4sV!|IH7A@Ly4TcQo$^{v z+EflAdZ9DCi4iKFceLOBPV9wKV0qp`h7J0Sgi)a$eD_R@7cPa3orHR3vRn9RLh}%hKbtXTxYdl3AX|w2GAQIC zI$AOWw&75buxMaJ(NZ|buf}1Z3k+Db6}z~fMF~2>I5-0oK+XWOB(xWBvNIg_zWyc7 z*u*XRU|>&WrA!*~ z(eN{7H`FXNA_)KL1itok;tnu}oS+r2(~cUg@|+w5(0@Sbv=m_q7BpsP?fJS1=z2LU zRIdZmPFjGWA0J_D9#?f)Pe)k1D@K8-AadyO@v)BW?1ZH@0q4#B>~4x>CLn{)0aH%K zE#&DfI?qQKH7j(kfIF)Yz@qs*EUy8i+WV+M_oD>_P(eY#f0tcZSy{w+U1Ou$e-j+O zJpzrj%JOnMM@MCvIM5eha)2+ms2uiWa-8_1LQq#_;db6SQA<*hY?`pvTo-DR=-1If zqJ;rQV+bM>sEZ`CT`_c{^xaAE2%CF4ySrhUT3T>HCx<~Vr#k;G)c8G~AnOaY>obS+ zv9uwwzv>46wXRU{y(~(aOc!wjdSu`K)_Hv|Whic(Ko*bh&>`L_T>Eh)g>?B({HJJJ zh>5E4KqXyfy>!9MX-8J`GeL%I3mV;#jQBe~DqVsPVr_m|0Sh18r6)^n`L{p3qkanY zN4RJ!61aadx{*$3-^dc{6yRzj&Y_z5_S5N@`=>wx1zUNPDp5)daYHaq&jiZDpkEcH zKue1%Eh=%KQa|}FpFBL(S|tL5V!az4b*_i^PqjP#==%Om*b76Qo$YXY6Sat+;-T|s zYNr0&VXSvf$w0;G2+x)*2w^p0*xV{wInX%0+9>43+Pv%ZMp^++W;6pqXP_iFb4eEX)Bkxa&4f-N(1aRc=A9OfNo9# zHQZia6iqI$e?=2F3`qQ00E26IH=-#L6Q|MbTWvBM8IBrA*DVVhA!E;OEnGR3W{*pc zSWeTJI{{sPL`|?DIL`1HYwkF~fuw8~iVDr=RGWy2zKtHqolx%ls^c*E;O`XQa?h)5 zZ!b$XW>AKNLlv|B{^iV2e^Cx03$5I@Bf=jWIMquEiRi`cw)FYa-6_@Nmn9$CHgx}} zv-ZHaF~63(j7xS`kRD)AO@%}N0!|2$Ox_V=sKtl zW$hhkZC zhlC(c+pK=WFZi|ZSP8=Z+ZDR=_qCWbeB2-ex>o=Bu+9Luoujfh^#Pld%L>QrP$=4- z^uJ}!@Y{M2fDZ$RYI$?>xBJU|V4(}T|H#`Ps(2+Em>u&90D}%eNPba&|Br9!2CFhDLA$2)X<2k@?i-4KvSJU(Yl-#!_xNwn;AVJI5BP9Hcd6zZiijm*jT31m z>gWi$QFb;YqXlMx+CHOM&AILPEEsv^fQd|}r`^hx3R0jd5t6@;xquax6y)iAWA8&1 zev;(kZ1daoQD6C@^<6&@2m{$(*h%Hx8y>9WM?(Q-Wbb-5MbAkld+&oMji`6W)^i`z z2{O)TwMqN25u}jdU*{^xq4E$Y9c?w^304`}(P`azStQawacz!X33O&!EnKC=Qnri( zH@eV;MDTo^_pA{h9hz|+F}6u=@pdKePQO>BB8UI2&DZzYG+cY!kjt}FB9ldDmgFS<{Ce$f5drv-;j8}TKwGY zP}iTf=Z#I#jalH#0fN`qkcO`6fcf{w;wU}Zcw--9gw>+fTT9-O(ojTGpR&0IzXzF_ zxsx}!bK!;XZ~wv)dc)4;t}Ryd7syuu(y z%iTXh#9@;F|MT$^oDo6Bm76!^`5}Qh)747%xA?Peny;|s*~RH^GSSM@1z+>+X>usn zPfo^0?r~k!o9DTK`l4WU!kFGG!DZQ;oV3IOiAX52T_nsWk`}a=^q>L?M3BRY=p?m^ z37vW-phv$>w?GSnS>7xnasnA^Ne(a|k^gJvKJXdhHS8UU^|&hrjdT_>nhztY9==42(InBG%A%)s9R zzvHNlkc$4u6(0XxhpLD(jDD3yAYYX^LzsBd9Sy;J_-)X&)wujlP!~R0g$dMHvDaLR z2kJgqJO6(c;BbN@%OM0;yO+fIlzkK}6IDv;Us;htqJz_jsZJehB9vWVXXVNf+mTQV zhay^ifZtBNjp0U(kF*BW2O^C3qvbcZ<#S6uu_~HW-I>58a{9x!`I9@ZK$s+XE}iDn zs^CXex)b!>ZJN`Ie`CKt^(!=BGl!8)WodK|23Rf7Gom*Z%xrmtQ}v>mn5BUJI*THa zXFbs8t1lt;e)ypd{FmwAy{f0-IIUc}EBG`RzW3?;uti~qwMgj+c$i$Q&5%GefFsBG%~%;d*TyB*_} zjU4OGK8}j3j%0rNay$=JeHg@`KyHxdDDPZVl`YXs(chRqPhwl)@9itf6wwh8DMDl? z#--pA;MUsm+v~LQ&JGxH>(?A3>4KGr2K)IZ9FnwIcz3uMd2dE)`|C#|MW#epHWE#g z=-!iy?j>EPD%bhe&bD7VPq&v@jhyGY&<)9@Ev?6@_oAC1NPKKL8u?x9D7JDg%tG>f z*EIZDkjs=RpRn?k$gRApQ?lU3lCh(j@)(Ecus|3XJy+^6Wmqv9<;Z8i?<^nhAEbva zgTT?6mbQPN$N9HJ+`AcmDl<_rNfZ={83U((0Sa{X)(&tlol8_3fRv~8W;@&>gG_77!)nrg6N6w_jGg?7p%GMljb|S_U{ihYiAoj ziQD%^lW6s&50VAdd{&gkwj-o^kTN*RD5Zbf%bMyPVWIhQs_(-=DE^gVO!j!2v#2D2 z_?c}K97)VfGpM{9)w%|qE*nM$oBKvG&3YXIH!X|RLOYk$7ue)(q#0{%sV0yN%QyV| z>Zm5tx_Qyd_Ar_WOkiKG@4Xx_C+XR9UP0<91R*bj?u@01(7bGhKm6kLyHeJyONdAw zR^B8)5fg`f8_{id)Ahe~RYr;uX@5E^ir|qy7pOsErvoLJ1J&Q!!TccJnaDTyF|jTC z65F=>hvqs8>-&2U+Ij8DYjpTfFPbblK%jGuE0dRnvo&`4Qy@6kpPLQQ)+9nF&&p-?^1@Pq6SK2T z-EHt0svg{0KieBX&&q%tf)_~|og&aUu7Dl10C`!xQ0tZfOMioi3d8q*2!4B#MHAz)XHyfkBh- z^Sia?i|@n?>&o>E?`ir=P*%s~ZjuIq7@v{8@4rSAPxI#_hyXCQh;fEweglRD5(f;~2a9}ls zF&hRJdCf(uN64gEj>$-vKKG5`ua;3M7Zik}Z0-j9pqe9>BUjHwk9oS|21E(sYNY8u zJ(k>CK^$OjB@Q&8dNCzE2P@~w9CMkM-)PUFPObMi z{To~~Mg@~OTMdLv;aYwYmOThT0-56iI~>;?gP6b`{l1-DZ*~wh!2Fn(&u3ds2P7KJOTtI7yK1D7I$e7g{LO1VpsG*kb}$pvrVtNLDNCY| z$COvmVyJNNmvddqWwn6O>HcnZyAqO`)6BrLwR0(G-&Ipor4nMGIyBz~x(skiv!=E% z5xA9iOc*19;`T9J4BFyXy1=qxa7^{j@0yRxEVtZ$rY#>fbY(23e_=MZ58T(b#&_ef z=W6FldH(Br3v7YnAc$EF3=6*Z?6r<+Gdd3x^KX`#?%rD`pOKmimTb+bxmrs5ooN3` z;+Ft#(aXO&GLE^$d=ryT7TIjlGmc2fsz>;;;$`Ln!CyJMPtQ%9qD;G42i(~ zn;%~{+0vw~Qf#h}G9dy)@{O1z#z``vt)i)_EMS9rTZ&t{ z5#5KWpux~EH(G zE9+$+5aal7SQ&j;%S}j&E&n8`{qj-N3&rPzcCS^a-H#Xq6i$;Y3t9xP=#U6%;Gq)N zE68t^MD2{GYJOZZ7__v|Z)xce5&zsm2sP6aF6OY%x!qw}!mC#oi?N`|J^%9MPiRF} zhCqHR9anDcZ2`WniX9|wXWsY>OowSik-K>rrIoP$y}%Ly`7x^-yYt(@GcR4_4~NIc z5dFmj3a`d(LQHo*{O}ljC`{UQd7G0#0m`pTc+s~quPJnwmGGKFA$ZiZv3qt=1{)Mo zE)l8P6qu6rNlta^X!38?`$-&1PEa#=YHG{AtjY7U-(QYaW1p3oCC~Aj-#^BkB&SGf z(I*2QHWcI9*we&8C^TZ5ePkVbOyuC5C+&Ye0@HQ3C=if3nXh&L3%^IRU_(|vrgOiiOIvx53(%_P06CX?4*#Bf{ag=%rQ%kBMkRbry!S56WRJkF?; zkJX%4r8=fRoH7n^R$AER))dQfL=f}8^3f54k8zmlEtbbLnhP@Ea2s;0i}Y301&N5^ z)qWUpvv^$)CyjT}nf6Z$hO=94RiP*#kik~l?Zb_G-y~r30H0Lv?+aYBv8!7q#bK0AE+}YS1Kp`utEJ5<(iak|2mb3#ZS-@r$gr`5z@C1-!-at9Xz?zW zqDYC}(7}b;SjRLu?e(za7zQV&4Ji)(`VJg8TYdmT8095=7;}g7Z5g2;8DpXS*TpQM zLGL72rsLINz28a4eWBlx7J!ZiD9p;r)d{-4B#BfEpCPUidUK@zPP+fcHC|mdIa?kY zy8PVS&#iCl7_@Q0SMT+R_Mi_22z`kD9tvB%196zhC443? z=WjVFCh>mdD?VTRHt#4KEqYy*!qtA0Z`?tPb;#{xHEvA#ppF-e%;p}9zj--KekAjn zU`nwkT&gB_l;t(lNvVzz$Ios8wOth{gfG*oKAR!hkHF zQy@m)w}&J4iVtSooood8Aj!%2`LRJ^U2OG1M0k&1P5HefRF7wS<7zZ373p*$ljL#S zPhCzn?Mqa7?C=^-Qf+p8ec@g|#R+?Wi!D0+{Am)Fg(ZqbxYn1ov~^M^hS#+`g`ccS zJr+VfpZqxs9=I`toZ8iN`8aIm4=e8X8?zlTBFF3&LEzos0LFkdp-PKSrk=+3-X0l$ zmL!O)6OPH%H26WFa?asc@`&Qz52%UZ6|cSg5U!fvW||XYjAQS`PYZ*n|S*Q26X&Hd8#}uF)8(w5>5xx1uZ=Nc^GE)BUp#bo&Q* zE@_hFrOUlzZYsUje#EMh3B%86fJn^BPJvl>4W~ZAN*u`u4-CQ?7a!HppRe`M^y&)T z+l?gY{PwA@P~To$#sk_|(dj)_Ty1K-_sjn9Q<=h7sPfQ`u&?nz{HO(4fw8Gq2ncepsh9bF7B3L7m_2=DAREbc zC`U1aCgMFm_h|%=p4kO=y)pi5li?|~s8wNgU%S1Oiv1Aw^+3ws} z_-A5zV2=*f{^h)e_K@=}E)?qE;FhKJN9S!5Uj|pL*yRUGH&GDM>{1b0H#%Wd81=6PGX*7>GQHP01GwxP_G_mVdMc} zEky#s*tXRx^6wJoVx-)KlfZ6QBJ25B&%x1k50m8W@!G`d3{H~@SHS5NR3en<7c6y# zJ-|k!v8xgm04sSUFuZ9KL6wUg}D;r3j`uAhwi1VrN*B&*$jl1abI)2q!nW1soTik~kzlO>5TnaPe(sz+< zWnyBcS8qPvbEjnKcaEbUy6oHIkm%Cg_>>yQ zI}TWcVv0_M)$#6x$gR7_*B*tp!c{XmeRKBl>l-tT` zZN^cbi{DQa=+8ghcFjwEXtaWGG3SGSJW$v~TXlP$b&PXUud`-wwYKtkFP`OHtcm#j zZd`k|weHs#1EI~-Sdf!Tz>#E?z8Jx2Zc1?I_k=KjP6XnQ7Qd@1mK3hs)5B37Tq}#X zI21HG7{%cIc+xBfXIp0g_zd z>m#A*_w!+$H3i+)6>{fGwf#c{`;1a(KWpxcEuI(<Ox|U(ThWIUshjsdJSDi|``}t1el2dnlSFi*D5`~>pr2SVqO$2IzJPyOBcnU} zR-ujg+PycCv3Bq=-HA{rv3XPu35W0M%yTw7zh%?9iiz zS38GoTVw2NEt||#k|*z^!=v$UzMD@gpZeKOkK7Z_qGBI>I^ttUxSn&c_Bj9TTE+C| z-Rq^r8z3A`8SO|#jfwfjsa$kYx1qN3F5Nmn=9>N$GL!P?j=udQ)8gYwtd7)6SI0wS zRIb$~=y~kq_m(Ux6$Jt~2gx6n^A;@wM+Yeo^n*qU4Xh>^L>?+l4=sk%>En5qd=H#L zg7{CR;q6mXQRU>*y0=|C*CcIX64B>u-TD}cvbJ;pUn^$rnJJbQNUB_uym|G;$-xB- zVe(&M&092&L%(-#^*Wm%fP`?UISXV)b=s8lC>_*NTjB+?<)*?z?DQJaWqdp3@Pcwl zCPVmkX@3r;1(1J{tF>LEdg?7uQ_w63W8g>$ z4-fRt+YdW+w14WW)Rj@pATOL>^9iBj2v!S+zYEFiUZMKxiLyEztv37XcUkO+bRU$F z=iyZf`B&|0o2T{P^84Vi=A9sr5s9FB`x#fMyjUW3iZEtR0(e}IQJG}s0 zeM59Ei$Y%JmhE8*UONe7C(p{p@wW|v16{Gsk+i$|tqE0gi)rW7J;_S_=p<=lqw{|RYnADnz@{J7 z_thm%N!%(uA#$12aB?>79Ry#ciUSXaMZd6cZMF3)^D*Kqu&HN-&E^Eg^0mQN=Z5+H zU;Hft@q&wQcCXz?CKC9yw8VQ_(+*1debbx8%LKmA+;uO$J&6?X5>)JzlOG=%=T5=& z#A2rz-6?ozHX?i7L(KyJ_?Kfg(PqosaCKBlTkTx?F%y*Ck1G`eZw;JFZnsi6SB&d6 zOL6i~4hf4eJojZSC5BjYVXH$jYF!@A6s#FysV4ZHXxLbAahr#bLPA^_y|_ya2;}yu z!MIYf=E44|O`m4o@jm&=6hPxbaLpm}?*&twFU@`=1-H6kDK^M|cekGYsr}`vk(+0s zPyFYg=3z-`-0kh*6m2zWg2}P?Q?q}9`{&1kYD>&_(hRGert%#HtsV+b(%7!!HjNou zCDn`FQSf+v%@LRTgQZgc&-*v~RWrAIXD*?C^FQq{Csni^{DNAR8wH6Ls?TBUaPt=7 zX(gaKD9zNnkf!S9jP=R!TYe0=cd#sB^xW~g(NGI2C2sj@t6_fT!*HQ8lWgX@C12Q~ zw5=^~UIb}i=+S#%sjozZK;rnfNwx)B)N~T}4>BGZo6cF+MwCYrf4QM7_O~!8v=FDS zWyES}v;8wXF_5>Qw`8bLqHZ@(+}$mf{@vbh9B!5((||4H=B8qStPhlU8YWou`lr0*<>3EAg-w^UqN^`{VH_aBqYnD^)csakO z4EsYy}0^4nsdqZ`f=-`gGYxZvT9w@$ltbbYztWK*bwm=BGF!27nIwCbyu0; zAZ@JdudeUY3_C4XF6S11WXb3wg3RkFYy7n5v|mV_-1Z(o5+to_(&QJ6%^aeSuLCfMZ>{i)p(-R@_g)m*UKlD)wKhstp%x8&JqQ+h4E9VRpX`qcwf z!xh5L?1hkuYxj0n&+KCvLDy1dyeZU@l(;_EfO(JmyAm@&K6>28c-wgAh_8*R6Jv38 z=PlXOp$u4%xbSf2#rU|&BSziWzy#u_Q_kYklMe~qNEIF8@p+}o39f3RM1G&a^q<7{ zT@O@M8)(MT&(vmXT|G`5+EO_gc_iQANtM3cwr_t5+QK2MwFT{RPU2c9b*(OpO-Y&S2K!Z}{P>P^d|GzPAM#o1+0rw9^}83zLHhiqAT8 zuc|W%@iP6=;jg}b7%O(E7+^nXav6yKX+x|kVtUs;y!)H?J}T;04JZ1v$nR-= z;i+EdUZag3rJnqGE(l^y)5Skq zOxw7KukC$B2$8gR^vF}_6*#n%n=PLLTwe>(E~*JLCWndRC5O~;oLn1Xp2Ddn-wdws z_9gc_7mczI!9V0P z#W9X@;38M3#=4R`CFitiKSjPfI&s%yetT>; z!tRtXBuSBAcc=~?57cT{O`*&V$d zD#p9MoeAMwzxOi^`Wi^m9}Dog@0ThEdKwg37R#pQHD~Apo(#~xyjaP8@o*IWw8;22 z!7|~WGR_!dYuY^ewtNvOZOdQO2|0_Mvgq1sMA`}d&3-fK+B?gQe)DbA0sfdVKpFVB zsBQMqDBSvZ_2}1WQ?Xi6dK(qx0U~T8hst=ac#qrCC9of90Lh<-mc=>N{2BI=QY4+% zrkd5CqLg=MEaC1>>%}0nOBRo`v&7SM`nKd@k92x*x;q5PAIY5B-g#R#k)P5;Zzp!l zJj5(S?oC|63t!y!_m`w(CU!Zf_C{?#wRTEDob%#JYb93!he)bcAe~Epz72y2ERFYuTMDp0J3yCQbI>ST{0nA zcEw3aIXKF*g;~W!RFpM%_`@M{)Qi{Cgm;SMfOGt_nA*uFNh+Kq3=T1VOXR-0WEHlw zqK|so`H4d_r$sC3hxPi+#6c?F zNaE2{ExH$}yt8%EzNCZAOz0nDPkDj~xr>Sl+>4FKBjxeJIjp%;vqa$Kh80Y)LJB+s zvWZtYSickguez^{s%j0lwiOf+L{Sh_q(MrQ21Pvh%L9=Dv$Hso&j%FEiEc8wx9 zZ$}9Go}97m3(1cIEA~F&mf|rklnQa;UGV9)r3-3Ht>%K!vt1`2mBn2cy2}$7a;nsw%&BJJon-e<_Su*@Vr#oYN7Zfj zku;bM{J2^xFw!ZH2@T2G+1TtKKQ5$6Y6ceagrDpmnn)ARm$Ck-d*$_WX09jP z=Te1rCMe4~lI~6V2GaYH=15QrH|%+0s5z)pq&Bf$7@1#t@U@V(OwC}RvZ23Ejd=iN z_a&v@F#_b?{I8gg3=1mkI=7`VsInVB!Rt6fw>GRl%jr$YGILJf{d-~Eo~GHsozGZl zrcP}rxXlgIa`qCF4oDxpSX^(mq;ov$7x~lxHytA}J+U?eS8eOsuA}tfhWd;?^x@q4 zwp4}gk*T_s8*kfk#?@qGHKEmzD0lCO=`|FBc{8Tvsjik#K$%pV`RxfGHC>*|!{$ zkJn_TgAH|?8b-Ta^R@+JkC%_nJyEsFQXbG|RHZfe)ogXW!<8Mn(=>%-;geYM`I_*m&GoxFPU2TxvYG5M=HfpZ^Nc2ooihQXo+`;F+;m5c$yM|1JGX29(9}2;pBc-gaSt?-E((p0 zkXp3rcwlJ8M@1fF)TbF0ltNb@9vCPcI;vggafUcBqpl&08!&g2yV|fJ{Hu?8X3FF* zg)Y`$?z;-p&S!;u^)d*&Ib5KS-DfF=<7_{T*`lJ@du`F&w^>8lCW9N1ni~@&v+`*} z)6{)!LVZ7MLLcZ0tjhC{k@?W&w^;ALSSVQI%JH_Qrz)*S;E9nxM}1E9weYdWhn6`a zaztc?uS*QQ5M81RO{09Sq8=)MBKwHQ%2cQF&UQ$~Q@QZrmOaWhe92xvU(GS33nUJ8 zIrAjkY{dW7ZQb%KN7Sf7G?=&&eq~r4R;!iE7@WM@$h_$rb?1=B^`m>=ve#0MOjC_a zN7xD(Z|kwFEBxZO=04A+cpmfo<0_>$y;1n1SF`RHCBmCe>Auve`;i#r@X0;V-4}eC z+(@&TxN45JNO0%Wqusx13#fwar02MH2=A4hpwv#PThiQZob}gYTa6COsKZwVx*+Bw z$Jl+6Chk9&)~Bzgf8`sd^r`EfjbVS8xrYx*L4s&xLY>PQ8TaTO4j%Dd7IwF;(@2T* z!?YKU1bJHt@R5ao5qK-#eedMzJrT+MO!aLNg8Kf$q zcpluKkUtVa7Mt*?wy@_w^6#^S9ZQBff6>uTW@QN6DAxYSJR|I5G^`x@D#zcyHtK{A zE6+%Qhwh~jBPW{IKEvMyJF|Y8zkX&q-R_wGjNZiCyFh|IE^%7&99I%mz1-;2w>y&} z_65tz@@+*&*fBCp*PORyiyW+Gz0!I>R(I!XAF`to)-} zShY|M4<`$mqkPi5$b_#g6db+9w_w)Y$SHDRT3o-XlP#Z<;jXS?wQO-vP{>sWDIX$M z>(XntzwR-jI#E@pYDXUA<87_U##5^B(6ZH$f3(K&=ZzVwjNg+ZLvgA-uPANKB|hF+ z(Q?+;o!vTGV^g7_(e=+G*PmAQl@`(gQKg~ADlx-0c2Um@H7pv_$M&o(nrM!Ld%^;6ML1dbG^WOc1twiLda%}tg0!EykB#JuT*)2wAG;~N=li{fkRjHl< z{`IN2{v6#1Ht1xLP7*S%oJfCBbxYyxA(?t(e>Ky*IPRD2EFES3)$bF2<~{qF^mgRt z-~<0}4EhIFb+v1VFUWHGSEPLKDEi4m7sj&W-)6EltG_KbnI!ja+8ZcG=pZKbxN z7cw%Uoa=c79IBjy-uT}(-gQsST_OGW_mqy*Xd05kwyaDZ?Qu6K$2dYmG_Rg?e6{oU zy$i$nayi98o40hRO52i}-p|Vv2k41D9INphzn7Yy>mT?=9dIwV>Qub%#;o~_N~dhu zkXc+{j}O~x^9!VvBH)oqXH>d)g^3<^JO-mIry& zCYP>BUwyTU_1bWW97S01mB9!01!8V;1#8n7?|V#CQW&Ihw!a- zpNO_ixf{!z*J;K?ThlA5HZLd~)z;z7;)p29qg~@4q50@ue){j{Xz!r*sv!OQT}9Q| znSY*3{?78i#y|gg|J1I3A7K9f@5g?%9Nd6gT3l}5#DsRTLNpwUtm}P78pO6+M{UBx z!vj`1U~#4TA~rPCb~^V*Bwxp#_CVarTdz znh35t;%#Z_G^b9ns;BYVj5Okj&0Zoq?L{Yl73RWoi1zQ-b80@_JkyXt7AL25JjPyl zVyA(D0rjQ(DI2GbAHVeASAo9J4SRhKouXFk1;fkOcsSysnL@PSMKQ5$netS6nbg!& ziG{(}dShFXy=5+=v7Tu!KYZYgR>;5o$(_mN<4nfyeR~4%Yx$mn>AXSglJH!@F8k++ zi3agnzZ&DOJ`(JHdp(kPQuE_|&7`UQ49a9leV?XTB!7j`#+H|Fyyj9ZUO%O6J7b!iej4!84U+x(lRnK?*51v93IZrEp^=9+6-d6zP7bKO|Z0M z+AO;sc}XY7kcez?va7=izZHMZ=kDQgo>XjmV?KdY-`JRvn)((d_rC_kV5~KDIAa68 zC>$Idm{dDUOO_ADwV~&IOu9arw^R4`_sfN_9;2i*F6HLn$ikfYzh`opn|Vs1%=%E# zVJkIeD3ZwicYnWu!_6ew5X>+336##)3stU5DJv@3-N^75@5Y;;-4SK1Tx_ zW*$F&{OX*v=`8mN+==98))zww1=R;(Q z`8P>-wPTNqYO(Dkd|?#-{x+Ck!7@!sN=mfrs7LZBLbb$oa}8q}{PrvHF;;#dq5OL- zcjpGGA3S(axn&zFW++!%^yxO&i9?~WZKipPMjuTaOSe8{-&N|Ps3kd|YgqsHE7YsM z>iJPg%Riqzm#6MoSe&M!D!TIp`=14wZ@oAz784Nx3qc|4p*rl8W~LSxhOywr+IV*C zM&Xwr6^Yaek-^nv|8FaJynRr$&GZtiy=2Ao841B{N7rHRj-Q~zvzY0&eV-yqvBO^EC#XWKV4b6wK zq30*^!NS7A&TbjWpq22~6B84Y!+u)8>T3?x2MiGlvi8T3^Dylx zv>t9xQU21>lB=GsVcZbYUFu|q9VjIwCAcTUX!2a7);*jNPOKn> zkGx{9l@+kFwavPb-IHCuwN_BB%FMX!u>0pn^IHgM9UUDkZ#XTCAeQzTIW>7c@gAxR zS54PAkGVmF`qK!t T3nj)8jZy6)D6=H-}KZvpkTM}$Pua?Hv{b^yrCP_RnH1yKL-xU}dme((^TjxkqVw>%6 z&BA=On*r7W_A|F{-VDR$#O&;8>^P(3)V*K0!rY#%rS9fA zZ*No6d%!vF!s%r)Z4F!}a36oLJ92F7XNKm5@p@ChHI0z1KV0ST>DtA;n$FAk&2>-P3LxmI$QX`wpE+KrkXA=7$?#Y{7H(Y&=2qB9EJge?IV{(PYL#@ zUyl`a*;ttsvYmL>^Mv~nGFwq}l^%lU;#eCXN{S10Rq`9lB2E>FD}Kni6qVrBpRc#)n_eGD+ul=p7V( z3X}@r)DvZ9#s)o6LF=JIWCB7$d?t+*IbX2BdC_qbhLG6VRV|SJL5N~Aq}!}DMH$u$ zIUhu$kU0Q-Y@c=xJbjo}?Ku@lUm~(u1kNBj#kQ8%aJx-mX1?;%j zEpzVd?k<%HVmv41zXEFqIGk=x=J!fTOS8Gn!H|oV>vjdq2pggVa^Z7|X}y_m`Xl_p zHxu5epLuMIjCU7Cnown3U0wUX1t?y>o>#VeGm?;Cz(5Qg_8_hU+r9;ycI!PTuB)qy zGMt#0$f;LW0_U!$PoKgpfKBoF32yzK5(jK15_8>Hc}jHj8R_W<_vUVv*v|sHPf=3Z z4c3Ifa#cb~N-N*8;)2=&3H$~)=;#t~XI`YIdp?>+>SntQhTO=~Eln%==>2&HmG2vN zcz^vGCFE$O-@XBry$o^SXh97@bDZ3Re;Ie)UU$% z?|C%#YrY)EV6tJ+nPXIFHMoV_N5#HQQ5hh*_TAA7H>nuTV}u>RI5&c30i`cQsu~gb z(Y$L4po_!Ywm0qM2`|FlP(RnPc+2r?qjB7Wjw~Hi>kJD& zfKviFxGK}Ov`rKpOG`@`85!J!PLU0?UD`6iWnufYCwf|15oSC0@Am=~v%|0`37knl zsnkeQ8$~9;k}4-4paJg)ya(n&N0b4rn2a6_3wR$BJ$+py-*j*DSEuEvQvFKeS_?NM zB--Ns+^RVyT|`7=ye)lsbGesb9RrMlx0;yU)Gyy-Y*awcGUrwBnHN+S z$I7P3Pn;;QpY0=Hc2UtYFE8Ah({xX%=aK_j=SvUx4{}s3P3;~gNI*aU zS5iKGCGG?u$-KL0F&A|Km+9f|PIc*i!TnC2ahkFA%yn4It*)kHQjksj4Rb(^jv7eOul}5VGyW5}4vP*}ChBR{T{y@pTsVAS4 zn+sysg7_R=bj;Jrd}H^stBd;3z9eGx52$tUIuPLHZTRnD+Clh3liRS$jq@R}-1=Uckx`$@e3Lvn8daW^HLntuc56#qvaF z3ko5fY1hX|TxN*NC0qv)xuEUD3J5383runWu$(-35>cKYojF<2a>FJy?4p?lfB;2W zC(pDEp@an2NL8tH+#gi^;eKb1)N?8V1p%EWSVp{6jOCDNCb$B|ik2cP;AZ4#Z!aMs zQ39TXai&Y4i^&NM3**Nfcepts*w}^SqXpY~d$$RkKqbi+z78x%_|as7LZ40(34C%e*2j?_S#a% z#hem3)GvWy)MsIv5hV;kM$k#EJkv+LcJ}~n$;rbA$zC|UB6kr19f8qCKfs((ig1M5 znmL9dIXEmq>>f}lTpMhU{QdOC1A82KUdIO=& zODj!ySqVg@2;>t-p!x$ZD~sCRfeHjzJ04@xSeTp35oT9U3vS+LnSDdop>i%)H~WU^ zr1XnZujpBj7ISJ3AHGAGnX@i;-Be>$zkK;J-e_cLYvw=bhTW)GeW&iek{%IHLe1;O6P->6fsldUJg_-?UAZ zWnL*E6T`ncH;6oY_hFQoX!tm`;@2$A%sgb6>B=#ZyL~&Z`SQu*$3L2NWcmC1V@gj6 z+FbM5=VMawcqgh0+T=;p1CSN|;Tp;5CR3rA;O<+j!~{lWVv_PGz>jD5OG!Wu$l`g@ zNIna+7>+OnMwK@-G;Gb#Y?;#mDU+3DJxlW|5S|>Q(X1kEufSr{!t?>wB?WqjJpRCzh zlH6-FiGKXw$Fs}J&@sc$X?x}iDX7KV{5;!--DH!Uxyg;@)!p5jJ@hGVavHGEs7E;i zvq||t0tWE5@h~bRQW$o3{A5J#N00(|QBet^YGMmIVln>lk58Y@^6*%}I59px9$k*w zi&SUd>9X~##v`I~5hoMMm?T06-y3{8e1a9IYDO!VsB z#K{RG_3hg>+A3rd`s?y(i8D&>^b8CTI_?Ad7RNjC@1Nqj{n&_{GN*|DDY76&So5BF z{vLZIzLm9gSJ5LgM@K?5ggiT2?|}IJXwm!cl|XxV0}uO}tpJU#C&{B_NqxH8wjBl2 zxuMVf-`9k&QEDBL>n!w6&bXKN6Ws=dFMr1Ao@F{Ijn`kr`KI(U>P4&@`gcv?8pLcT z!t(R;8J_jz8VB%HRGaXkt470XgQ&my?9h9EiSld|K9RF$|DfdtSzzVOJV@4-sw$nN zf@bdX97iBMECyu~o4{225gUlC&}iJhV{d*Kzhxi6WEfNgy-8zZqj;saw}i64kI(xU z>Sx~0&dw;IHW14=r<$F@tX54g{?N-3&m>qc|mbvgM9!(gPjEZvpUGd}@3H8w6Af7F7 z_T0I1=p!IpcpawEdav)f*vg|sPI8K0>7?@g(xx9jG9yCJsT(;dDZK1H8#+{LSjeNzbc~ltQ|fZ4@6K1FP(g13RYtQnIe68HT><^U z=Gvp$FPXRU5jAToE3Mgj+X#gOX05Vuwi{W@9)JAo$Kvra1RD#NfA@uHrA)Ez< zbUz7|4d{Z?(IjDEVW}6V1fch!2Egyh0AvaCs?Ng&CiQ!GxC4j-=}Zs2AA~kPhN&vT z&(Cj;ynZ!6Y|9>8LtOnm5)J5^2F8U}OALYl=xf>5+Bp2=@-X`&ijH~&js|E&xU4Tt znvJGxfPzD!TwfiI)dLKmE~5#&S>`MtxEABiqWYFyyWk;CPT;f;|9r;fdy!BdKXvMU zw5pw6z*5NKbniC41+;q#2srC;h=9Twu-HDKzC@w3E(iM+= zG{W{=LPF~^f4<;(6P$f?`xI51_~HPUh`PIIWMv;TS3KE`L!|>~br!fqFsOiJ^!mux z4B+C8NlKq zo1|JPCKILENE60CQDOU-W z4S7Pqq%n?QP$S}G3;$(weEB_}>g%UqMA-#=tn{OV!5|;xEqoV|dm~)P&tI0G)ecX`6#&1OegrAYlXh~TT z{04P-5mFJVc&@|zP)*&9n^cP|DbS0i%eP%|C{*0Nd-q~avt>hA(d4f+P>TM%@z!Q% zo=H>R6~_6;$yJq=M(DL5UeH`L1?71NEFnL7w7;)!>%tc203-x`|Gj$%ueva91884p z)FmgjVLNr#$cUx0GGLjA_B+tK8+{Zytn!6sIk1sf5vPv|$*TJ0VPRq3-rnE8U*qJ= zM(>X_C`ZI8{t5mqcFbiraX$RMGCD zyr7^Kg#P*S&m`2qLPkbLAe({uN9Z}vO5VSp!Q=KsHfas=78=MSXf}=<;ereVMT@O} zHvtXpot&njatH|t(bLm&am`??bn+iW=k$zTW$`fz)HG(^j09t$pgOakGY)X{8oI{K z!=B`e2VT$!X+XP!5JJ(BKT{Vk5ke6)f7R=-dZt!ev%4+27--#y1}&A6nEjPE6ApM1 zdmnhs!u)`<-iQ_1#mCnH?JUO#H3Lg5Vh|B72(v{lD0z-KOw8_n?1Xe-V`s0sn;^=3 z<%9ttVi4WHUd+ZLh)zYy|K`u2-1n3q!$7ML0F-o+01Ad zm&6KI+@5PPGOGYc*v|^H;Z~Lx7oU53GwYQ(^EY`IAUcP(@$^Hi*KIyeC zS|VMf1JHlldX3q>IcCi2@QxXiU)|jy;kS3j5J6|a*FaSgb8BbXwPOdvRf0hI;of{k z!>+BJBCh`|l3hCdIJlcjxyaM{KWXczh@Q>#iT-Ho`lsP8W_^8H1tRunmGeL(Q0=P zUrY1zLXXCRjcOrvL91J8RruZcS@54Po*7Y5Q9;3d2sw+u9B;n6jrb;A`CM05$KfB8#<&xU4oD5J7M{2 z*Bbv`KYRcFyj&9aBy&Rv=c|57?3GL;0*{MCA(tZEOftWY-FVJXd?O!JS_j%PHqrm5FLYR8%yX3o*7yn$)Ye-3F8{u{9A6d56~~n$!!b$qOW-%s!5G1{Qi!bP2bCzdRwYOhE!%FC|f$cyt2)6@}dGaT?oR%hla-a&Rw&O_m z(Vo(ERodv4m48F7;@lcOXwhBNR_5YF*KC>xMiL8OHn%d_ObsY79-nSwZH4Q^#KZ*L zA>ohZ2Ld1^A%P_iNak)xZIDOJV%yBQK&(SykXBY!xT;{Vd!(h!Z2t8hc_3(uANdeA=o(KJcfa(Y#0zmGQ0)R#rU!zL({F!&guu+p~Ud=5h zN5?XlvO;z0#%)8r?ZE1dL?C^tn&e(lFenmYVk!I~2@enCx7w!~Jb5Jd;U94sJhL@Z<5qU$4dycxd!ATlZD3`ahR!!2AYVB0zo@!s zE6XORBV!Qc6&=Xi)6GeKNo*<(LUcJLiFt^g=`Q>_g`qNsfrCSVLUJ(U{_cO5`|T;P zws7O^qxWy+ofGPu2(kkf?NCItL7|~Isxn%>pIKf8z{~0D_W{oS&m>Nj=ltoY^mH>R zsVaQVhnbsu7HfrX-ETJl%R4(A&;X)KWp7>nEB>n2Ydu%YN=6}*4 zdPB4M-(M#@_WB5_gsNqOBO7h8-h|1)nFGA>COyO zAwG0SZRlAz+z0;_ru|WXae71wTA0K)+M)%>Ha0Q?EYl*tQZw4)>ME*FtfO3`W7GL~ zzZ3cQbxl2id26ng69@-l6^Tu6F+ z*ug;Tdt)|~Sv-E1_S?9EKRkOYr|ukV+r8J-u~d|aX;@j2=>8Q2g%`TnoLPzS5<6un zD6;#`D?b(Ai+i>FVnpoQ{pEPte&UTo`>x)-JCqnh{+D}QoxH~nIx?#mmT7-?c^FdEZYR5P#jU~+yhOxa~#~j(6fEuFN`$Gx#c{=-$Sq z&3-0aTH-O*C8b1hz`wPX6>t)oTzVU+!!xD86VI!#gl0My1GcK`qY From e703909d9b2b14633fbbbc983c7429b0877f78c3 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Mon, 13 Jul 2026 14:27:25 +0200 Subject: [PATCH 528/707] Set PARALLEL_UPDATES for netatmo platforms (#176391) --- homeassistant/components/netatmo/binary_sensor.py | 2 ++ homeassistant/components/netatmo/button.py | 2 ++ homeassistant/components/netatmo/camera.py | 2 ++ homeassistant/components/netatmo/climate.py | 2 ++ homeassistant/components/netatmo/cover.py | 2 ++ homeassistant/components/netatmo/fan.py | 2 ++ homeassistant/components/netatmo/light.py | 2 ++ homeassistant/components/netatmo/quality_scale.yaml | 2 +- homeassistant/components/netatmo/select.py | 2 ++ homeassistant/components/netatmo/sensor.py | 3 +++ homeassistant/components/netatmo/switch.py | 2 ++ 11 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index f230a33c8e8e..e8bb4e15a18a 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -42,6 +42,8 @@ from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_OPENING_SENSOR_KEY = "opening_sensor" OPENING_STATUS_TO_BINARY_SENSOR_STATE: Final[dict[str, bool | None]] = { diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index 8c7b536bf8fb..3273023e8941 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -17,6 +17,8 @@ from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 692149f34a2f..a05558a975d0 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -43,6 +43,8 @@ from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_QUALITY = "high" diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 4e50972286c5..177ef99a7efe 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -56,6 +56,8 @@ from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + PRESET_FROST_GUARD = "frost_guard" PRESET_SCHEDULE = "schedule" PRESET_MANUAL = "manual" diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 2244de089830..089964e2ab1a 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -22,6 +22,8 @@ from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index c5e01c5904a6..6505eeda9eaf 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -17,6 +17,8 @@ from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_PERCENTAGE: Final = 50 PRESET_MAPPING = {"slow": 1, "fast": 2} diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 2de7dbcf7ee6..d132b0f75876 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -24,6 +24,8 @@ from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/netatmo/quality_scale.yaml b/homeassistant/components/netatmo/quality_scale.yaml index e6d59d83b40e..9896b2e2d8f4 100644 --- a/homeassistant/components/netatmo/quality_scale.yaml +++ b/homeassistant/components/netatmo/quality_scale.yaml @@ -37,7 +37,7 @@ rules: entity-unavailable: todo integration-owner: done log-when-unavailable: todo - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: todo diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 32c9fe74ee61..78492fecd9a9 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -21,6 +21,8 @@ from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 2b1f9bc90593..f53e29d203d2 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -71,6 +71,9 @@ from .helper import NetatmoArea _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + + DIRECTION_OPTIONS = [ "n", "ne", diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 351d6d005fed..357edb673685 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -17,6 +17,8 @@ from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 134b1cbc4f26edad5d7ec148f9b89e7083345abf Mon Sep 17 00:00:00 2001 From: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:20:03 +0200 Subject: [PATCH 529/707] Restore KNX switch states (#176352) --- homeassistant/components/knx/switch.py | 8 +++--- tests/components/knx/test_switch.py | 37 +++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index c7db8212af17..dc9e19b8f341 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -79,17 +79,15 @@ class _KnxSwitch(SwitchEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.switch.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.switch.value = last_state.state == STATE_ON @property @override - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if device is on.""" - return bool(self._device.state) + return self._device.state @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index 025c034cacf2..480a585abfa3 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -6,7 +6,7 @@ from homeassistant.components.knx.const import ( KNX_ADDRESS, ) from homeassistant.components.knx.schema import SwitchSchema -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State from . import KnxEntityGenerator @@ -70,6 +70,8 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit) -> None: # StateUpdater initialize state await knx.assert_read(_STATE_ADDRESS) + # state is unknown until the first GroupValueRead response is received + assert hass.states.get("switch.test").state is STATE_UNKNOWN await knx.receive_response(_STATE_ADDRESS, True) state = hass.states.get("switch.test") assert state.state is STATE_ON @@ -111,6 +113,39 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_telegram_count(0) +async def test_switch_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX switch with state_address restores last known state until bus read completes.""" + _ADDRESS = "1/1/1" + _STATE_ADDRESS = "2/2/2" + fake_state = State("switch.test", STATE_ON) + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + SwitchSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: _ADDRESS, + CONF_STATE_ADDRESS: _STATE_ADDRESS, + }, + } + ) + + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(_STATE_ADDRESS) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # bus confirms restored value - no additional state change expected + await knx.receive_response(_STATE_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # bus reports a different value than restored - state updates to the real value + await knx.receive_write(_STATE_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + async def test_switch_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test restoring KNX switch state and respond to read.""" _ADDRESS = "1/1/1" From 75f4e11aff0f94c5915ba04ebaac0ed933e8169c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:11:09 +0200 Subject: [PATCH 530/707] Update mypy to 2.3.0 (#176409) --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 756d98098354..8873e7986966 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,10 +13,10 @@ astroid==4.0.4 coverage==7.15.0 freezegun==1.5.5 # librt is an internal mypy dependency -librt==0.12.0 +librt==0.13.0 license-expression==30.4.3 mock-open==1.4.0 -mypy==2.2.0 +mypy==2.3.0 prek==0.2.28 pydantic==2.13.4 PyGithub==2.9.1 From 2741fb85db2968edbbe84e3c6de020019edb610e Mon Sep 17 00:00:00 2001 From: Manu Date: Mon, 13 Jul 2026 16:11:37 +0200 Subject: [PATCH 531/707] Add sensors to Steam integration (#176146) --- .../components/steam_online/icons.json | 13 + .../components/steam_online/sensor.py | 117 +++++-- .../components/steam_online/strings.json | 16 +- .../fixtures/GetPlayerSummaries.json | 3 + .../steam_online/snapshots/test_sensor.ambr | 324 +++++++++++++++++- 5 files changed, 432 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/steam_online/icons.json b/homeassistant/components/steam_online/icons.json index f2a9deb155c2..9a62815c2bbc 100644 --- a/homeassistant/components/steam_online/icons.json +++ b/homeassistant/components/steam_online/icons.json @@ -3,6 +3,19 @@ "sensor": { "account": { "default": "mdi:steam" + }, + "last_online": { + "default": "mdi:account-clock" + }, + "level": { + "default": "mdi:trophy-award" + }, + "now_playing": { + "default": "mdi:controller", + "state": { + "unavailable": "mdi:controller-off", + "unknown": "mdi:controller-off" + } } } } diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 58190afb580d..355cae888e44 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -1,12 +1,17 @@ """Sensor for Steam account status.""" -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from enum import StrEnum -from typing import Any, override +from typing import TYPE_CHECKING, Any, override -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -30,14 +35,20 @@ class SteamSensor(StrEnum): """Steam sensors.""" ACCOUNT = "account" + LAST_ONLINE = "last_online" + NOW_PLAYING = "now_playing" + LEVEL = "level" @dataclass(kw_only=True, frozen=True) class SteamSensorEntityDescription(SensorEntityDescription): """Steam sensor description.""" - value_fn: Callable[[PlayerData], StateType] - entity_picture_fn: Callable[[PlayerData], str] | None = None + value_fn: Callable[[PlayerData], StateType | datetime] + entity_picture_fn: Callable[[PlayerData, dict[str, str]], str | None] | None = None + extra_state_attributes_fn: ( + Callable[[PlayerData, dict[str, str]], Mapping[str, Any]] | None + ) = None SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( @@ -45,8 +56,58 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( key=SteamSensor.ACCOUNT, translation_key=SteamSensor.ACCOUNT, value_fn=lambda x: STEAM_STATUSES[x.personastate], - entity_picture_fn=lambda x: x.avatarfull, + entity_picture_fn=lambda x, _: x.avatarfull, name=None, + extra_state_attributes_fn=lambda x, icons: { + "real_name": x.realname, + "created": ( + dt_util.as_local(dt_util.utc_from_timestamp(x.timecreated)) + if x.timecreated is not None + else None + ), + "game": x.gameextrainfo, + "game_id": x.gameid, + "game_image_header": ( + f"{STEAM_API_URL}{x.gameid}/{STEAM_HEADER_IMAGE_FILE}" + if x.gameid is not None + else None + ), + "game_image_main": ( + f"{STEAM_API_URL}{x.gameid}/{STEAM_MAIN_IMAGE_FILE}" + if x.gameid is not None + else None + ), + "game_icon": ( + f"{STEAM_ICON_URL}{x.gameid}/{info}.jpg" + if x.gameid is not None and (info := icons.get(x.gameid)) is not None + else None + ), + "last_online": dt_util.utc_from_timestamp(x.lastlogoff), + "level": x.level, + }, + ), + SteamSensorEntityDescription( + key=SteamSensor.LAST_ONLINE, + translation_key=SteamSensor.LAST_ONLINE, + value_fn=(lambda x: dt_util.utc_from_timestamp(x.lastlogoff)), + device_class=SensorDeviceClass.TIMESTAMP, + ), + SteamSensorEntityDescription( + key=SteamSensor.NOW_PLAYING, + translation_key=SteamSensor.NOW_PLAYING, + value_fn=lambda x: x.gameextrainfo, + entity_picture_fn=lambda x, icons: ( + f"{STEAM_ICON_URL}{x.gameid}/{game_icon_url}.jpg" + if x.gameid and (game_icon_url := icons.get(x.gameid)) + else None + ), + extra_state_attributes_fn=lambda x, _: {"app_id": x.gameid}, + ), + SteamSensorEntityDescription( + key=SteamSensor.LEVEL, + translation_key=SteamSensor.LEVEL, + value_fn=lambda x: x.level, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -58,11 +119,12 @@ async def async_setup_entry( ) -> None: """Set up the Steam platform.""" coordinator = entry.runtime_data - + if TYPE_CHECKING: + assert entry.unique_id async_add_entities( SteamSensorEntity(coordinator, entry.unique_id, description) for description in SENSOR_DESCRIPTIONS - if entry.unique_id is not None and entry.unique_id in coordinator.data + if entry.unique_id in coordinator.data ) for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND): @@ -70,8 +132,7 @@ async def async_setup_entry( [ SteamSensorEntity(coordinator, subentry.unique_id, description) for description in SENSOR_DESCRIPTIONS - if subentry.unique_id is not None - and subentry.unique_id in coordinator.data + if subentry.unique_id in coordinator.data ], config_subentry_id=subentry.subentry_id, ) @@ -84,7 +145,7 @@ class SteamSensorEntity(SteamEntity, SensorEntity): @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data[self._steamid]) @@ -93,40 +154,20 @@ class SteamSensorEntity(SteamEntity, SensorEntity): def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" return ( - fn(self.coordinator.data[self._steamid]) + fn(self.coordinator.data[self._steamid], self.coordinator.game_icons) if (fn := self.entity_description.entity_picture_fn) is not None else super().entity_picture ) @property @override - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of the sensor.""" - player = self.coordinator.data[self._steamid] - - attrs: dict[str, str | int | datetime] = {} - if game := player.gameextrainfo: - attrs["game"] = game - if game_id := player.gameid: - attrs["game_id"] = game_id - game_url = f"{STEAM_API_URL}{player.gameid}/" - attrs["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}" - attrs["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}" - if info := self._get_game_icon(player): - attrs["game_icon"] = f"{STEAM_ICON_URL}{game_id}/{info}.jpg" - if last_online := player.lastlogoff: - attrs["last_online"] = dt_util.as_local( - dt_util.utc_from_timestamp(last_online) - ) - if level := self.coordinator.data[self._steamid].level: - attrs["level"] = level - return attrs - - def _get_game_icon(self, player: PlayerData) -> str | None: - """Get game icon identifier.""" - if player.gameid is not None and player.gameid in self.coordinator.game_icons: - return self.coordinator.game_icons[player.gameid] - return None + return ( + fn(self.coordinator.data[self._steamid], self.coordinator.game_icons) + if (fn := self.entity_description.extra_state_attributes_fn) is not None + else super().extra_state_attributes + ) @property @override diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json index 1d9289147bab..fdb497f21fea 100644 --- a/homeassistant/components/steam_online/strings.json +++ b/homeassistant/components/steam_online/strings.json @@ -95,13 +95,27 @@ "snooze": "Snooze" }, "state_attributes": { + "created": { "name": "Account created" }, "game": { "name": "Game" }, "game_icon": { "name": "Game icon" }, "game_id": { "name": "Game ID" }, "game_image_header": { "name": "Game header image" }, "game_image_main": { "name": "Game image" }, "last_online": { "name": "Last online" }, - "level": { "name": "Level" } + "level": { "name": "Level" }, + "real_name": { "name": "Real name" } + } + }, + "last_online": { + "name": "Last online" + }, + "level": { + "name": "Level" + }, + "now_playing": { + "name": "Now playing", + "state_attributes": { + "app_id": { "name": "Steam App ID" } } } } diff --git a/tests/components/steam_online/fixtures/GetPlayerSummaries.json b/tests/components/steam_online/fixtures/GetPlayerSummaries.json index ad3aaab0c5b6..d3aa4bf87dc0 100644 --- a/tests/components/steam_online/fixtures/GetPlayerSummaries.json +++ b/tests/components/steam_online/fixtures/GetPlayerSummaries.json @@ -14,6 +14,8 @@ "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", "lastlogoff": 1775409487, "personastate": 1, + "primaryclanid": "1234567890123456", + "timecreated": 1273953511, "realname": "John Dough", "personastateflags": 0, "gameextrainfo": "The Witcher: Enhanced Edition", @@ -31,6 +33,7 @@ "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", "lastlogoff": 1775409487, "personastate": 2, + "timecreated": 1303243041, "personastateflags": 0 } ] diff --git a/tests/components/steam_online/snapshots/test_sensor.ambr b/tests/components/steam_online/snapshots/test_sensor.ambr index 23261040871d..c06916174d98 100644 --- a/tests/components/steam_online/snapshots/test_sensor.ambr +++ b/tests/components/steam_online/snapshots/test_sensor.ambr @@ -39,6 +39,7 @@ # name: test_sensors[sensor.testaccount1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'created': datetime.datetime(2010, 5, 15, 12, 58, 31, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', : 'testaccount1', 'game': 'The Witcher: Enhanced Edition', @@ -46,8 +47,9 @@ 'game_id': '20900', 'game_image_header': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/header.jpg', 'game_image_main': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/capsule_616x353.jpg', - 'last_online': datetime.datetime(2026, 4, 5, 10, 18, 7, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc), 'level': 10, + 'real_name': 'John Dough', }), 'context': , 'entity_id': 'sensor.testaccount1', @@ -57,6 +59,162 @@ 'state': 'online', }) # --- +# name: test_sensors[sensor.testaccount1_last_online-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount1_last_online', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last online', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678901234567_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount1_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'testaccount1 Last online', + }), + 'context': , + 'entity_id': 'sensor.testaccount1_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-04-05T17:18:07+00:00', + }) +# --- +# name: test_sensors[sensor.testaccount1_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount1_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678901234567_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount1_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'testaccount1 Level', + : , + }), + 'context': , + 'entity_id': 'sensor.testaccount1_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.testaccount1_now_playing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount1_now_playing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Now playing', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Now playing', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678901234567_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount1_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'app_id': '20900', + : 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg', + : 'testaccount1 Now playing', + }), + 'context': , + 'entity_id': 'sensor.testaccount1_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'The Witcher: Enhanced Edition', + }) +# --- # name: test_sensors[sensor.testaccount2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -97,10 +255,17 @@ # name: test_sensors[sensor.testaccount2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'created': datetime.datetime(2011, 4, 19, 12, 57, 21, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', : 'testaccount2', - 'last_online': datetime.datetime(2026, 4, 5, 10, 18, 7, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'game': None, + 'game_icon': None, + 'game_id': None, + 'game_image_header': None, + 'game_image_main': None, + 'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc), 'level': 10, + 'real_name': None, }), 'context': , 'entity_id': 'sensor.testaccount2', @@ -110,3 +275,158 @@ 'state': 'busy', }) # --- +# name: test_sensors[sensor.testaccount2_last_online-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount2_last_online', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last online', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'testaccount2 Last online', + }), + 'context': , + 'entity_id': 'sensor.testaccount2_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-04-05T17:18:07+00:00', + }) +# --- +# name: test_sensors[sensor.testaccount2_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount2_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'testaccount2 Level', + : , + }), + 'context': , + 'entity_id': 'sensor.testaccount2_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.testaccount2_now_playing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount2_now_playing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Now playing', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Now playing', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'app_id': None, + : 'testaccount2 Now playing', + }), + 'context': , + 'entity_id': 'sensor.testaccount2_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- From 5bf09409d5d8cb2f4e2363032e96191f3dca9777 Mon Sep 17 00:00:00 2001 From: Amit Krishna <218109745+amitkio@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:50:06 +0530 Subject: [PATCH 532/707] Add exception translation keys to energieleser (#176390) --- .../components/energieleser/coordinator.py | 18 +++++++++++++++--- .../components/energieleser/quality_scale.yaml | 2 +- .../components/energieleser/strings.json | 11 +++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/energieleser/coordinator.py b/homeassistant/components/energieleser/coordinator.py index bb4a06e5a4e6..e71e076425d5 100755 --- a/homeassistant/components/energieleser/coordinator.py +++ b/homeassistant/components/energieleser/coordinator.py @@ -52,14 +52,26 @@ class EnergieleserCoordinator(DataUpdateCoordinator[EnergieleserDevice]): device = await self.client.get_device() except EnergieleserUnknownDeviceError as err: raise UpdateFailed( - f"Unknown or unsupported device type for {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="unknown_device", + translation_placeholders={ + "device_id": self.device_id, + }, ) from err except EnergieleserConnectionError as err: raise UpdateFailed( - f"Cannot connect to energieleser device {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={ + "device_id": self.device_id, + }, ) from err except EnergieleserError as err: raise UpdateFailed( - f"Error communicating with energieleser device {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "device_id": self.device_id, + }, ) from err return device diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index 6fb61d22a818..7173e0886296 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: done repair-issues: todo diff --git a/homeassistant/components/energieleser/strings.json b/homeassistant/components/energieleser/strings.json index dbfaf215b536..7065aec00bc3 100755 --- a/homeassistant/components/energieleser/strings.json +++ b/homeassistant/components/energieleser/strings.json @@ -92,5 +92,16 @@ "name": "Water today" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the device {device_id}" + }, + "connection_error": { + "message": "An error occurred while connecting to the device {device_id}" + }, + "unknown_device": { + "message": "The device type for {device_id} is unknown or unsupported" + } } } From 0b83bb23350d5d740fb9eae520a70bef657009d3 Mon Sep 17 00:00:00 2001 From: Sarabveer Singh <4297171+sarabveer@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:36:59 -0400 Subject: [PATCH 533/707] Add split-phase option to Tesla Wall Connector (#175883) --- .../tesla_wall_connector/__init__.py | 7 +- .../tesla_wall_connector/config_flow.py | 74 +++++++++++-- .../components/tesla_wall_connector/const.py | 3 + .../tesla_wall_connector/strings.json | 22 +++- .../tesla_wall_connector/test_config_flow.py | 102 +++++++++++++++++- 5 files changed, 194 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 986c9b600af0..c5ca5d0c93b8 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE from .coordinator import ( WallConnectorConfigEntry, WallConnectorCoordinator, @@ -23,7 +24,11 @@ async def async_setup_entry( """Set up Tesla Wall Connector from a config entry.""" hostname = entry.data[CONF_HOST] - wall_connector = WallConnector(host=hostname, session=async_get_clientsession(hass)) + wall_connector = WallConnector( + host=hostname, + session=async_get_clientsession(hass), + split_phase=entry.options.get(CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE), + ) try: version_data = await wall_connector.async_get_version() diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index f7965760ca5a..aa1b9c24c0bd 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -7,17 +7,57 @@ from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import WallConnectorError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import BooleanSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER +from .const import ( + CONF_SPLIT_PHASE, + DEFAULT_SPLIT_PHASE, + DOMAIN, + WALLCONNECTOR_DEVICE_NAME, + WALLCONNECTOR_SERIAL_NUMBER, +) +from .coordinator import WallConnectorConfigEntry _LOGGER = logging.getLogger(__name__) +class TeslaWallConnectorOptionsFlow(OptionsFlowWithReload): + """Handle Tesla Wall Connector options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry( + title="", + data={CONF_SPLIT_PHASE: user_input[CONF_SPLIT_PHASE]}, + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SPLIT_PHASE, + default=self.config_entry.options.get( + CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE + ), + ): BooleanSelector(), + } + ), + ) + + async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. @@ -45,6 +85,15 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): super().__init__() self.ip_address: str | None = None + @staticmethod + @callback + @override + def async_get_options_flow( + _config_entry: WallConnectorConfigEntry, + ) -> TeslaWallConnectorOptionsFlow: + """Get the options flow.""" + return TeslaWallConnectorOptionsFlow() + @override async def async_step_dhcp( self, discovery_info: DhcpServiceInfo @@ -91,7 +140,12 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" data_schema = vol.Schema( - {vol.Required(CONF_HOST, default=self.ip_address): str} + { + vol.Required(CONF_HOST, default=self.ip_address): str, + vol.Optional( + CONF_SPLIT_PHASE, default=DEFAULT_SPLIT_PHASE + ): BooleanSelector(), + } ) if user_input is None: return self.async_show_form(step_id="user", data_schema=data_schema) @@ -110,11 +164,17 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): unique_id=info[WALLCONNECTOR_SERIAL_NUMBER], raise_on_progress=True ) self._abort_if_unique_id_configured( - updates=user_input, reload_on_update=True + updates={CONF_HOST: user_input[CONF_HOST]}, reload_on_update=True ) - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=info["title"], + data={CONF_HOST: user_input[CONF_HOST]}, + options={CONF_SPLIT_PHASE: user_input[CONF_SPLIT_PHASE]}, + ) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema(data_schema, user_input), + errors=errors, ) diff --git a/homeassistant/components/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py index fac6a3d46bb1..4be4a41d6c1f 100644 --- a/homeassistant/components/tesla_wall_connector/const.py +++ b/homeassistant/components/tesla_wall_connector/const.py @@ -2,6 +2,9 @@ DOMAIN = "tesla_wall_connector" DEFAULT_SCAN_INTERVAL = 30 +DEFAULT_SPLIT_PHASE = False + +CONF_SPLIT_PHASE = "split_phase" WALLCONNECTOR_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index c21822edc180..192d493d91e0 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -1,4 +1,8 @@ { + "common": { + "split_phase": "Single-phase / Split-phase electrical service", + "split_phase_description": "Enable if your Wall Connector is powered by single-phase / split-phase electrical service. This affects the calculation of the Total power sensor. Leave disabled for three-phase supply (default)." + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" @@ -11,10 +15,12 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "[%key:common::config_flow::data::host%]", + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase%]" }, "data_description": { - "host": "Hostname or IP address of your Tesla Wall Connector." + "host": "Hostname or IP address of your Tesla Wall Connector.", + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase_description%]" }, "title": "Configure Tesla Wall Connector" } @@ -97,5 +103,17 @@ "name": "Wi-Fi RSSI" } } + }, + "options": { + "step": { + "init": { + "data": { + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase%]" + }, + "data_description": { + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase_description%]" + } + } + } } } diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py index fc1f41995155..2fe7a9f5fe94 100644 --- a/tests/components/tesla_wall_connector/test_config_flow.py +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -1,17 +1,29 @@ """Test the Tesla Wall Connector config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from tesla_wall_connector.exceptions import WallConnectorConnectionError from homeassistant import config_entries -from homeassistant.components.tesla_wall_connector.const import DOMAIN +from homeassistant.components.tesla_wall_connector.const import ( + CONF_SPLIT_PHASE, + DEFAULT_SPLIT_PHASE, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from tests.common import MockConfigEntry +from .conftest import ( + get_default_version_data, + get_lifetime_mock, + get_vitals_mock, + get_wifi_status_mock, +) + +from tests.common import MockConfigEntry, get_schema_suggested_value async def test_form(mock_wall_connector_version, hass: HomeAssistant) -> None: @@ -35,6 +47,7 @@ async def test_form(mock_wall_connector_version, hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Tesla Wall Connector" assert result2["data"] == {CONF_HOST: "1.1.1.1"} + assert result2["options"] == {CONF_SPLIT_PHASE: DEFAULT_SPLIT_PHASE} assert len(mock_setup_entry.mock_calls) == 1 @@ -50,11 +63,19 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: "1.1.1.1"}, + {CONF_HOST: "1.1.1.1", CONF_SPLIT_PHASE: True}, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_HOST) + == "1.1.1.1" + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_SPLIT_PHASE) + is True + ) async def test_form_other_error( @@ -131,6 +152,79 @@ async def test_dhcp_can_finish( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_HOST: "1.2.3.4"} + assert result["options"] == {CONF_SPLIT_PHASE: DEFAULT_SPLIT_PHASE} + + +async def test_form_with_split_phase(hass: HomeAssistant) -> None: + """Test setting single-phase / split-phase during setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "tesla_wall_connector.WallConnector.async_get_version", + return_value=get_default_version_data(), + ), + patch( + "homeassistant.components.tesla_wall_connector.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_SPLIT_PHASE: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {CONF_HOST: "1.1.1.1"} + assert result2["options"] == {CONF_SPLIT_PHASE: True} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + options={CONF_SPLIT_PHASE: False}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tesla_wall_connector.WallConnector" + ) as wall_connector: + client = wall_connector.return_value + client.async_get_version = AsyncMock(return_value=get_default_version_data()) + client.async_get_vitals = AsyncMock(return_value=get_vitals_mock()) + client.async_get_lifetime = AsyncMock(return_value=get_lifetime_mock()) + client.async_get_wifi_status = AsyncMock(return_value=get_wifi_status_mock()) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + assert wall_connector.call_args.kwargs[CONF_SPLIT_PHASE] is False + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + wall_connector.reset_mock() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SPLIT_PHASE: True}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_SPLIT_PHASE: True} + assert entry.options == {CONF_SPLIT_PHASE: True} + assert entry.state is ConfigEntryState.LOADED + assert wall_connector.call_args.kwargs[CONF_SPLIT_PHASE] is True async def test_dhcp_already_exists( From 00e6713cfebe396f2bfb2158c39d79377ef14a1a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:52:05 +0200 Subject: [PATCH 534/707] Use EntityStateAttribute enum in WattTime (#176392) --- homeassistant/components/watttime/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index bbd310bece50..cb78e17031d0 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -9,10 +9,11 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, + CONF_LATITUDE, + CONF_LONGITUDE, CONF_SHOW_ON_MAP, PERCENTAGE, + EntityStateAttribute, UnitOfMass, ) from homeassistant.core import HomeAssistant @@ -97,11 +98,11 @@ class RealtimeEmissionsSensor(CoordinatorEntity[WattTimeCoordinator], SensorEnti # Conversely, we can hide the location on the map by using other keys, like # "lati" and "long". if self._entry.options.get(CONF_SHOW_ON_MAP) is not False: - attrs[ATTR_LATITUDE] = self._entry.data[ATTR_LATITUDE] - attrs[ATTR_LONGITUDE] = self._entry.data[ATTR_LONGITUDE] + attrs[EntityStateAttribute.LATITUDE] = self._entry.data[CONF_LATITUDE] + attrs[EntityStateAttribute.LONGITUDE] = self._entry.data[CONF_LONGITUDE] else: - attrs["lati"] = self._entry.data[ATTR_LATITUDE] - attrs["long"] = self._entry.data[ATTR_LONGITUDE] + attrs["lati"] = self._entry.data[CONF_LATITUDE] + attrs["long"] = self._entry.data[CONF_LONGITUDE] return attrs From f88f91144cf5156d845e5fc41af9504f368eb620 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:53:09 +0200 Subject: [PATCH 535/707] Use EntityStateAttribute enum in Tankerkoenig (#176393) --- .../components/tankerkoenig/binary_sensor.py | 6 +++--- homeassistant/components/tankerkoenig/sensor.py | 10 +++++----- .../tankerkoenig/snapshots/test_binary_sensor.ambr | 4 ++-- .../tankerkoenig/snapshots/test_sensor.ambr | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 9d6aba087081..336c3e4377d7 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -56,8 +56,8 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE self._attr_unique_id = f"{station.id}_status" if coordinator.show_on_map: self._attr_extra_state_attributes = { - ATTR_LATITUDE: station.lat, - ATTR_LONGITUDE: station.lng, + EntityStateAttribute.LATITUDE: station.lat, + EntityStateAttribute.LONGITUDE: station.lng, } @property diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index e551432b5f4a..06483fec5714 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -6,7 +6,7 @@ from typing import override from aiotankerkoenig import GasType, Station from homeassistant.components.sensor import SensorEntity, SensorStateClass -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO +from homeassistant.const import CURRENCY_EURO, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -76,8 +76,8 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): ATTR_STATION_NAME, ATTR_STREET, ATTRIBUTION, - ATTR_LATITUDE, - ATTR_LONGITUDE, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, } ) @@ -104,8 +104,8 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): } if coordinator.show_on_map: - attrs[ATTR_LATITUDE] = station.lat - attrs[ATTR_LONGITUDE] = station.lng + attrs[EntityStateAttribute.LATITUDE] = station.lat + attrs[EntityStateAttribute.LONGITUDE] = station.lng self._attr_extra_state_attributes = attrs @property diff --git a/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr b/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr index e4657b6f4e37..999c2fb26661 100644 --- a/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr +++ b/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr @@ -3,7 +3,7 @@ ReadOnlyDict({ : 'opening', : 'Station Somewhere Street 1 Status', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, }) # --- diff --git a/tests/components/tankerkoenig/snapshots/test_sensor.ambr b/tests/components/tankerkoenig/snapshots/test_sensor.ambr index 3b65840cb246..55788bd12fd4 100644 --- a/tests/components/tankerkoenig/snapshots/test_sensor.ambr +++ b/tests/components/tankerkoenig/snapshots/test_sensor.ambr @@ -7,8 +7,8 @@ : 'Station Somewhere Street 1 Super E10', 'fuel_type': , 'house_number': '1', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, 'postcode': 1234, : , 'station_name': 'Station ABC', @@ -24,8 +24,8 @@ : 'Station Somewhere Street 1 Super', 'fuel_type': , 'house_number': '1', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, 'postcode': 1234, : , 'station_name': 'Station ABC', @@ -41,8 +41,8 @@ : 'Station Somewhere Street 1 Diesel', 'fuel_type': , 'house_number': '1', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, 'postcode': 1234, : , 'station_name': 'Station ABC', From e49ae0812a1d78f842b5713e99ee8a62f3ac6886 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:05:11 +0200 Subject: [PATCH 536/707] Use EntityStateAttribute enum in AirVisual (#176394) --- homeassistant/components/airvisual/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index d6d3380e72d9..879bf6ec932f 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -9,14 +9,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_STATE, CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, CONF_STATE, + EntityStateAttribute, UnitOfDensity, UnitOfRatio, ) @@ -191,12 +190,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): ) if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]: - self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude - self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude + self._attr_extra_state_attributes[EntityStateAttribute.LATITUDE] = latitude + self._attr_extra_state_attributes[EntityStateAttribute.LONGITUDE] = ( + longitude + ) self._attr_extra_state_attributes.pop("lati", None) self._attr_extra_state_attributes.pop("long", None) else: self._attr_extra_state_attributes["lati"] = latitude self._attr_extra_state_attributes["long"] = longitude - self._attr_extra_state_attributes.pop(ATTR_LATITUDE, None) - self._attr_extra_state_attributes.pop(ATTR_LONGITUDE, None) + self._attr_extra_state_attributes.pop(EntityStateAttribute.LATITUDE, None) + self._attr_extra_state_attributes.pop(EntityStateAttribute.LONGITUDE, None) From 128b9a2202b5bb31ccf93c9f60b05deb58a80262 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:06:22 +0200 Subject: [PATCH 537/707] Bump guntamatic to v1.9.2 (#176416) --- homeassistant/components/guntamatic/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index e6ce097ba43d..09f3f81b6060 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.9.1"] + "requirements": ["guntamatic==1.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a5e968f9ab9..60c46c60a21c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ growattServer==2.1.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.9.1 +guntamatic==1.9.2 # homeassistant.components.profiler guppy3==3.1.7 From 0c9499ca660f722d82b29df12d7f1a6b5e39da9b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 13 Jul 2026 17:07:23 +0200 Subject: [PATCH 538/707] Delete legacy issue template (#176414) --- .github/ISSUE_TEMPLATE.md | 49 --------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 2783972953b3..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,49 +0,0 @@ - -## The problem - - - -## Environment - - -- Home Assistant Core release with the issue: -- Last working Home Assistant Core release (if known): -- Operating environment (OS/Container/Supervised/Core): -- Integration causing this issue: -- Link to integration documentation on our website: - -## Problem-relevant `configuration.yaml` - - -```yaml - -``` - -## Traceback/Error logs - - -```txt - -``` - -## Additional information - From a6d2327244e3eadc4df4e6b59c51e6dc2f56b3e2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:08:04 +0200 Subject: [PATCH 539/707] Use EntityStateAttribute enum in CityBikes (#176396) --- homeassistant/components/citybikes/sensor.py | 7 +++---- tests/components/citybikes/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 94f952b7737a..81d859b2a978 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -17,13 +17,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( APPLICATION_NAME, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, EVENT_HOMEASSISTANT_CLOSE, + EntityStateAttribute, UnitOfLength, __version__, ) @@ -235,8 +234,8 @@ class CityBikesStation(SensorEntity): self._attr_native_value = station.free_bikes self._attr_extra_state_attributes = { ATTR_UID: station.extra.get(ATTR_UID), - ATTR_LATITUDE: station.latitude, - ATTR_LONGITUDE: station.longitude, + EntityStateAttribute.LATITUDE: station.latitude, + EntityStateAttribute.LONGITUDE: station.longitude, ATTR_EMPTY_SLOTS: station.empty_slots, ATTR_FREE_EBIKES: station.extra.get(EXTRA_EBIKES), ATTR_TIMESTAMP: station.timestamp, diff --git a/tests/components/citybikes/snapshots/test_sensor.ambr b/tests/components/citybikes/snapshots/test_sensor.ambr index 3da6b71e0b81..91bb5626036b 100644 --- a/tests/components/citybikes/snapshots/test_sensor.ambr +++ b/tests/components/citybikes/snapshots/test_sensor.ambr @@ -7,8 +7,8 @@ 'free_ebikes': 2, : 'Station 1', : 'mdi:bike', - 'latitude': 40.0, - 'longitude': -73.0, + : 40.0, + : -73.0, 'timestamp': '2026-03-22T00:00:00Z', 'uid': 'uid-1', : 'bikes', @@ -29,8 +29,8 @@ 'free_ebikes': None, : 'Station 1', : 'mdi:bike', - 'latitude': 40.0, - 'longitude': -73.0, + : 40.0, + : -73.0, 'timestamp': '2026-03-22T00:00:00Z', 'uid': 'uid-1', : 'bikes', From e73bf9bd38bbd8a81570b79122d9950f6323046d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:09:13 +0200 Subject: [PATCH 540/707] Use EntityStateAttribute enum in HERE Travel Time (#176397) --- homeassistant/components/here_travel_time/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 1992a0de97f4..19e8dfe88a19 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -11,10 +11,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_MODE, CONF_NAME, + EntityStateAttribute, UnitOfLength, UnitOfTime, ) @@ -184,9 +183,10 @@ class OriginSensor(HERETravelTimeSensor): def extra_state_attributes(self) -> Mapping[str, Any] | None: """GPS coordinates.""" if self.coordinator.data is not None: + latitude, longitude = self.coordinator.data[ATTR_ORIGIN].split(",") return { - ATTR_LATITUDE: self.coordinator.data[ATTR_ORIGIN].split(",")[0], - ATTR_LONGITUDE: self.coordinator.data[ATTR_ORIGIN].split(",")[1], + EntityStateAttribute.LATITUDE: latitude, + EntityStateAttribute.LONGITUDE: longitude, } return None @@ -214,8 +214,9 @@ class DestinationSensor(HERETravelTimeSensor): def extra_state_attributes(self) -> Mapping[str, Any] | None: """GPS coordinates.""" if self.coordinator.data is not None: + latitude, longitude = self.coordinator.data[ATTR_DESTINATION].split(",") return { - ATTR_LATITUDE: self.coordinator.data[ATTR_DESTINATION].split(",")[0], - ATTR_LONGITUDE: self.coordinator.data[ATTR_DESTINATION].split(",")[1], + EntityStateAttribute.LATITUDE: latitude, + EntityStateAttribute.LONGITUDE: longitude, } return None From 65c4b21ac4b350d63ec3827fc0f5589bd1396810 Mon Sep 17 00:00:00 2001 From: Steven Beshensky Date: Mon, 13 Jul 2026 10:10:23 -0500 Subject: [PATCH 541/707] Add Harman Luxury Audio integration (#175650) Co-authored-by: Claude Opus 4.8 --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/harman_luxury/__init__.py | 31 +++ .../components/harman_luxury/config_flow.py | 92 ++++++++ .../components/harman_luxury/const.py | 3 + .../components/harman_luxury/coordinator.py | 75 +++++++ .../components/harman_luxury/manifest.json | 21 ++ .../components/harman_luxury/media_player.py | 209 ++++++++++++++++++ .../harman_luxury/quality_scale.yaml | 82 +++++++ .../components/harman_luxury/strings.json | 33 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/ssdp.py | 10 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/harman_luxury/__init__.py | 12 + tests/components/harman_luxury/conftest.py | 86 +++++++ .../snapshots/test_media_player.ambr | 63 ++++++ .../harman_luxury/test_config_flow.py | 168 ++++++++++++++ tests/components/harman_luxury/test_init.py | 48 ++++ .../harman_luxury/test_media_player.py | 183 +++++++++++++++ 21 files changed, 1139 insertions(+) create mode 100644 homeassistant/components/harman_luxury/__init__.py create mode 100644 homeassistant/components/harman_luxury/config_flow.py create mode 100644 homeassistant/components/harman_luxury/const.py create mode 100644 homeassistant/components/harman_luxury/coordinator.py create mode 100644 homeassistant/components/harman_luxury/manifest.json create mode 100644 homeassistant/components/harman_luxury/media_player.py create mode 100644 homeassistant/components/harman_luxury/quality_scale.yaml create mode 100644 homeassistant/components/harman_luxury/strings.json create mode 100644 tests/components/harman_luxury/__init__.py create mode 100644 tests/components/harman_luxury/conftest.py create mode 100644 tests/components/harman_luxury/snapshots/test_media_player.ambr create mode 100644 tests/components/harman_luxury/test_config_flow.py create mode 100644 tests/components/harman_luxury/test_init.py create mode 100644 tests/components/harman_luxury/test_media_player.py diff --git a/.strict-typing b/.strict-typing index 735fa9e4361f..f878a42c6b54 100644 --- a/.strict-typing +++ b/.strict-typing @@ -255,6 +255,7 @@ homeassistant.components.guntamatic.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* +homeassistant.components.harman_luxury.* homeassistant.components.hdfury.* homeassistant.components.heos.* homeassistant.components.here_travel_time.* diff --git a/CODEOWNERS b/CODEOWNERS index f3cfd1aae06f..402a02066d06 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -721,6 +721,8 @@ CLAUDE.md @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core /tests/components/hardware/ @home-assistant/core +/homeassistant/components/harman_luxury/ @sbesh91 +/tests/components/harman_luxury/ @sbesh91 /homeassistant/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /homeassistant/components/hassio/ @home-assistant/supervisor diff --git a/homeassistant/components/harman_luxury/__init__.py b/homeassistant/components/harman_luxury/__init__.py new file mode 100644 index 000000000000..c8e4c9ae8d47 --- /dev/null +++ b/homeassistant/components/harman_luxury/__init__.py @@ -0,0 +1,31 @@ +"""The Harman Luxury Audio integration.""" + +from aioharmanluxury import HarmanLuxuryClient + +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator + +_PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: HarmanLuxuryConfigEntry +) -> bool: + """Set up Harman Luxury from a config entry.""" + client = HarmanLuxuryClient(entry.data[CONF_HOST], async_get_clientsession(hass)) + coordinator = HarmanLuxuryCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: HarmanLuxuryConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/harman_luxury/config_flow.py b/homeassistant/components/harman_luxury/config_flow.py new file mode 100644 index 000000000000..9008d164257d --- /dev/null +++ b/homeassistant/components/harman_luxury/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for the Harman Luxury integration.""" + +from typing import Any, override +from urllib.parse import urlparse + +from aioharmanluxury import DeviceInfo, HarmanLuxuryClient, HarmanLuxuryError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class HarmanLuxuryConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Harman Luxury.""" + + _host: str + _name: str + + async def _async_get_info(self, host: str) -> DeviceInfo | None: + """Return the device info, or ``None`` if it has no usable identity.""" + client = HarmanLuxuryClient(host, async_get_clientsession(self.hass)) + try: + info = await client.async_get_info() + except HarmanLuxuryError: + return None + if not info.serial: + return None + return info + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + info = await self._async_get_info(user_input[CONF_HOST]) + if info is None: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(info.serial) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info.name, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + @override + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initiated by SSDP discovery.""" + host = urlparse(discovery_info.ssdp_location or "").hostname + serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL) + if not host or not serial: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + info = await self._async_get_info(host) + # The unique ID is the advertised serial; refuse a device whose API + # reports a different one, so setup cannot later fail on the mismatch. + if info is None or info.serial != serial: + return self.async_abort(reason="cannot_connect") + + self._host = host + self._name = info.name + self.context["title_placeholders"] = {"name": info.name} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm setup of a discovered device.""" + if user_input is not None: + return self.async_create_entry( + title=self._name, data={CONF_HOST: self._host} + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"name": self._name}, + ) diff --git a/homeassistant/components/harman_luxury/const.py b/homeassistant/components/harman_luxury/const.py new file mode 100644 index 000000000000..6c8d79b7b6fe --- /dev/null +++ b/homeassistant/components/harman_luxury/const.py @@ -0,0 +1,3 @@ +"""Constants for the Harman Luxury integration.""" + +DOMAIN = "harman_luxury" diff --git a/homeassistant/components/harman_luxury/coordinator.py b/homeassistant/components/harman_luxury/coordinator.py new file mode 100644 index 000000000000..c54d0adeef2c --- /dev/null +++ b/homeassistant/components/harman_luxury/coordinator.py @@ -0,0 +1,75 @@ +"""Data update coordinator for Harman Luxury.""" + +from datetime import datetime, timedelta +import logging +from typing import override + +from aioharmanluxury import ( + DeviceInfo, + HarmanLuxuryClient, + HarmanLuxuryError, + HarmanLuxuryState, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type HarmanLuxuryConfigEntry = ConfigEntry[HarmanLuxuryCoordinator] + +_SCAN_INTERVAL = timedelta(seconds=10) + + +class HarmanLuxuryCoordinator(DataUpdateCoordinator[HarmanLuxuryState]): + """Poll a Harman Luxury device for its live player state.""" + + config_entry: HarmanLuxuryConfigEntry + device_info: DeviceInfo + position_updated_at: datetime | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: HarmanLuxuryConfigEntry, + client: HarmanLuxuryClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=_SCAN_INTERVAL, + ) + self.client = client + + @override + async def _async_setup(self) -> None: + """Fetch static device identity once.""" + try: + self.device_info = await self.client.async_get_info() + except HarmanLuxuryError as err: + raise UpdateFailed(str(err)) from err + if self.device_info.serial != self.config_entry.unique_id: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unexpected_device", + ) + + @override + async def _async_update_data(self) -> HarmanLuxuryState: + """Fetch the latest player state.""" + try: + state = await self.client.async_get_state() + except HarmanLuxuryError as err: + raise UpdateFailed(str(err)) from err + self.position_updated_at = ( + dt_util.utcnow() if state.position is not None else None + ) + return state diff --git a/homeassistant/components/harman_luxury/manifest.json b/homeassistant/components/harman_luxury/manifest.json new file mode 100644 index 000000000000..f935743154c9 --- /dev/null +++ b/homeassistant/components/harman_luxury/manifest.json @@ -0,0 +1,21 @@ +{ + "domain": "harman_luxury", + "name": "Harman Luxury Audio", + "codeowners": ["@sbesh91"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/harman_luxury", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["aioharmanluxury==0.2.3"], + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Harman Luxury Audio" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Harman Luxury Audio" + } + ] +} diff --git a/homeassistant/components/harman_luxury/media_player.py b/homeassistant/components/harman_luxury/media_player.py new file mode 100644 index 000000000000..322a3b4bdddf --- /dev/null +++ b/homeassistant/components/harman_luxury/media_player.py @@ -0,0 +1,209 @@ +"""Media player platform for Harman Luxury.""" + +from collections.abc import Coroutine +from datetime import datetime +from typing import Any, override + +from aioharmanluxury import HarmanLuxuryClient, HarmanLuxuryError + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator + +# The device serializes control on a single session; serialize at HA's layer. +PARALLEL_UPDATES = 1 + +# The device exposes volume on a 0..99 scale. +_VOLUME_MAX = 99 + +_PLAY_STATE_MAP = { + "playing": MediaPlayerState.PLAYING, + "paused": MediaPlayerState.PAUSED, + "stopped": MediaPlayerState.IDLE, + "buffering": MediaPlayerState.BUFFERING, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HarmanLuxuryConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the media player from a config entry.""" + async_add_entities([HarmanLuxuryMediaPlayer(entry.runtime_data)]) + + +class HarmanLuxuryMediaPlayer( + CoordinatorEntity[HarmanLuxuryCoordinator], MediaPlayerEntity +): + """Representation of a Harman Luxury streamer.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_volume_step = 1 / _VOLUME_MAX + + _BASE_FEATURES = ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + ) + + def __init__(self, coordinator: HarmanLuxuryCoordinator) -> None: + """Initialize the media player.""" + super().__init__(coordinator) + info = coordinator.device_info + self._attr_unique_id = info.serial + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, info.serial)}, + connections={(CONNECTION_NETWORK_MAC, info.mac)} if info.mac else set(), + manufacturer="Harman Luxury Audio", + model=info.model, + name=info.name, + ) + + @property + def _client(self) -> HarmanLuxuryClient: + """Return the device client.""" + return self.coordinator.client + + @property + @override + def state(self) -> MediaPlayerState: + """Return the state of the device.""" + data = self.coordinator.data + if not data.online: + return MediaPlayerState.OFF + return _PLAY_STATE_MAP.get(data.play_state, MediaPlayerState.ON) + + @property + @override + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the supported features.""" + features = self._BASE_FEATURES + data = self.coordinator.data + if data.can_play: + features |= MediaPlayerEntityFeature.PLAY + if data.can_pause: + features |= MediaPlayerEntityFeature.PAUSE + if data.can_stop: + features |= MediaPlayerEntityFeature.STOP + if data.can_next: + features |= MediaPlayerEntityFeature.NEXT_TRACK + if data.can_previous: + features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + return features + + @property + @override + def volume_level(self) -> float: + """Return the volume level (0..1).""" + return self.coordinator.data.volume / _VOLUME_MAX + + @property + @override + def is_volume_muted(self) -> bool: + """Return whether the output is muted.""" + return self.coordinator.data.muted + + @property + @override + def media_title(self) -> str | None: + """Return the title of the current media.""" + return self.coordinator.data.title + + @property + @override + def media_artist(self) -> str | None: + """Return the artist of the current media.""" + return self.coordinator.data.artist + + @property + @override + def media_album_name(self) -> str | None: + """Return the album of the current media.""" + return self.coordinator.data.album + + @property + @override + def media_image_url(self) -> str | None: + """Return the album art URL.""" + return self.coordinator.data.art_url + + @property + @override + def media_duration(self) -> int | None: + """Return the duration of the current media, in seconds.""" + duration = self.coordinator.data.duration + return int(duration) if duration is not None else None + + @property + @override + def media_position(self) -> int | None: + """Return the position of the current media, in seconds.""" + position = self.coordinator.data.position + return int(position) if position is not None else None + + @property + @override + def media_position_updated_at(self) -> datetime | None: + """Return when the media position was last retrieved.""" + return self.coordinator.position_updated_at + + async def _async_send(self, coro: Coroutine[Any, Any, None]) -> None: + """Run a client command, translating failures and refreshing state.""" + try: + await coro + except HarmanLuxuryError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from err + await self.coordinator.async_request_refresh() + + @override + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + await self._async_send( + self._client.async_set_volume(round(volume * _VOLUME_MAX)) + ) + + @override + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute the output.""" + await self._async_send(self._client.async_set_mute(mute)) + + @override + async def async_media_play(self) -> None: + """Resume playback.""" + await self._async_send(self._client.async_control("play")) + + @override + async def async_media_pause(self) -> None: + """Pause playback.""" + await self._async_send(self._client.async_control("pause")) + + @override + async def async_media_stop(self) -> None: + """Stop playback.""" + await self._async_send(self._client.async_control("stop")) + + @override + async def async_media_next_track(self) -> None: + """Skip to the next track.""" + await self._async_send(self._client.async_control("next")) + + @override + async def async_media_previous_track(self) -> None: + """Skip to the previous track.""" + await self._async_send(self._client.async_control("previous")) diff --git a/homeassistant/components/harman_luxury/quality_scale.yaml b/homeassistant/components/harman_luxury/quality_scale.yaml new file mode 100644 index 000000000000..e0c52e495ea4 --- /dev/null +++ b/homeassistant/components/harman_luxury/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any custom service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register any custom service actions. + docs-conditions: + status: exempt + comment: This integration does not register any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not register any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The device API is unauthenticated; there are no credentials to refresh. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A config entry maps to a single device; there are no dynamic sub-devices. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The single media player entity is the primary entity and stays enabled. + entity-translations: + status: exempt + comment: The media player uses the device name via has-entity-name. + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no repairable conditions surfaced by the device. + stale-devices: + status: exempt + comment: A config entry maps to a single device; removal is via entry deletion. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/harman_luxury/strings.json b/homeassistant/components/harman_luxury/strings.json new file mode 100644 index 000000000000..1cf7c1f6a672 --- /dev/null +++ b/homeassistant/components/harman_luxury/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "flow_title": "{name}", + "step": { + "discovery_confirm": { + "description": "Do you want to set up {name}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Harman Luxury device." + } + } + } + }, + "exceptions": { + "command_failed": { + "message": "Failed to send the command to the device." + }, + "unexpected_device": { + "message": "The device at this address reports a different serial number than the configured device." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0e8963704c87..22a5977d6e25 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -306,6 +306,7 @@ FLOWS = { "guntamatic", "habitica", "hanna", + "harman_luxury", "harmony", "hdfury", "hegel", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5d311722c091..dd31413921a1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2764,6 +2764,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "harman_luxury": { + "name": "Harman Luxury Audio", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "harvey": { "name": "Harvey", "integration_type": "virtual", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 28d71f8281c4..8409d1d8f436 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -135,6 +135,16 @@ SSDP = { "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1", }, ], + "harman_luxury": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Harman Luxury Audio", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Harman Luxury Audio", + }, + ], "harmony": [ { "deviceType": "urn:myharmony-com:device:harmony:1", diff --git a/mypy.ini b/mypy.ini index 1fbb8b1a495c..70d09d0ae3f4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2307,6 +2307,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.harman_luxury.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.hdfury.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 60c46c60a21c..5ce1aa247ce2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -281,6 +281,9 @@ aiogithubapi==26.0.0 # homeassistant.components.guardian aioguardian==2026.01.1 +# homeassistant.components.harman_luxury +aioharmanluxury==0.2.3 + # homeassistant.components.harmony aioharmony==1.0.8 diff --git a/tests/components/harman_luxury/__init__.py b/tests/components/harman_luxury/__init__.py new file mode 100644 index 000000000000..13b41935398c --- /dev/null +++ b/tests/components/harman_luxury/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Harman Luxury integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Harman Luxury integration in Home Assistant.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/harman_luxury/conftest.py b/tests/components/harman_luxury/conftest.py new file mode 100644 index 000000000000..f680515edf47 --- /dev/null +++ b/tests/components/harman_luxury/conftest.py @@ -0,0 +1,86 @@ +"""Common fixtures for the Harman Luxury tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aioharmanluxury import DeviceInfo, HarmanLuxuryState +import pytest + +from homeassistant.components.harman_luxury.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_SERIAL = "48b782c2-60ac-40c1-a169-8d7ccb81dcc1" +TEST_NAME = "Dining Room" + +DEVICE_INFO = DeviceInfo( + serial=TEST_SERIAL, + model="ARCAM ST5", + name=TEST_NAME, + mac="02:FE:6C:B7:EB:59", +) + +PLAYER_STATE = HarmanLuxuryState( + online=True, + volume=45, + muted=False, + play_state="playing", + title="Necessary Evil", + artist="Motionless In White", + album="Graveyard Shift", + art_url="http://1.2.3.4/art.jpg", + duration=228, + position=42, + can_play=True, + can_pause=True, + can_stop=True, + can_next=True, + can_previous=True, +) + +SSDP_DISCOVERY = SsdpServiceInfo( + ssdp_usn=f"uuid:{TEST_SERIAL}", + ssdp_st="urn:schemas-upnp-org:device:MediaRenderer:1", + ssdp_location=f"http://{TEST_HOST}:16500/desc.xml", + upnp={ + ATTR_UPNP_SERIAL: TEST_SERIAL, + ATTR_UPNP_MANUFACTURER: "Harman Luxury Audio", + }, +) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=TEST_NAME, + data={CONF_HOST: TEST_HOST}, + unique_id=TEST_SERIAL, + ) + + +@pytest.fixture +def mock_client() -> Generator[AsyncMock]: + """Mock the Harman Luxury client.""" + with ( + patch( + "homeassistant.components.harman_luxury.HarmanLuxuryClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.harman_luxury.config_flow.HarmanLuxuryClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_info.return_value = DEVICE_INFO + client.async_get_state.return_value = PLAYER_STATE + yield client diff --git a/tests/components/harman_luxury/snapshots/test_media_player.ambr b/tests/components/harman_luxury/snapshots/test_media_player.ambr new file mode 100644 index 000000000000..918c0857700e --- /dev/null +++ b/tests/components/harman_luxury/snapshots/test_media_player.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_entities[media_player.dining_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.dining_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'harman_luxury', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '48b782c2-60ac-40c1-a169-8d7ccb81dcc1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.dining_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'speaker', + : '/api/media_player_proxy/media_player.dining_room?token=deterministic&cache=1f1d8cd564c70097', + : 'Dining Room', + : False, + : 'Graveyard Shift', + : 'Motionless In White', + : 228, + : 42, + : HAFakeDatetime(2024, 1, 1, 12, 0, tzinfo=datetime.timezone.utc), + : 'Necessary Evil', + : , + : 0.45454545454545453, + }), + 'context': , + 'entity_id': 'media_player.dining_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/harman_luxury/test_config_flow.py b/tests/components/harman_luxury/test_config_flow.py new file mode 100644 index 000000000000..aee36205f34f --- /dev/null +++ b/tests/components/harman_luxury/test_config_flow.py @@ -0,0 +1,168 @@ +"""Test the Harman Luxury config flow.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from aioharmanluxury import HarmanLuxuryError +import pytest + +from homeassistant.components.harman_luxury.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import DEVICE_INFO, SSDP_DISCOVERY, TEST_HOST, TEST_NAME, TEST_SERIAL + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_client") +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == {CONF_HOST: TEST_HOST} + assert result["result"].unique_id == TEST_SERIAL + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test the user flow recovers from a connection error.""" + mock_client.async_get_info.side_effect = HarmanLuxuryError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_client.async_get_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_blank_serial( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test the user flow recovers from a device that reports no serial.""" + mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_client.async_get_info.return_value = DEVICE_INFO + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the user flow when the device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_client") +async def test_ssdp_flow(hass: HomeAssistant) -> None: + """Test the SSDP discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == {CONF_HOST: TEST_HOST} + assert result["result"].unique_id == TEST_SERIAL + + +@pytest.mark.usefixtures("mock_client") +async def test_ssdp_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test SSDP discovery aborts and updates the host when already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery = replace(SSDP_DISCOVERY, ssdp_location="http://5.5.5.5:16500/desc.xml") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # The stale host is updated to the newly discovered one. + assert mock_config_entry.data[CONF_HOST] == "5.5.5.5" + + +async def test_ssdp_flow_cannot_connect( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test SSDP discovery aborts when the device cannot be reached.""" + mock_client.async_get_info.side_effect = HarmanLuxuryError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("mock_client") +async def test_ssdp_flow_missing_serial(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when the advertisement lacks a serial.""" + discovery = replace(SSDP_DISCOVERY, upnp={}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_ssdp_flow_serial_mismatch( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test SSDP discovery aborts when the API serial differs from the advertised one.""" + mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="different") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/harman_luxury/test_init.py b/tests/components/harman_luxury/test_init.py new file mode 100644 index 000000000000..f08bf8cb7ea8 --- /dev/null +++ b/tests/components/harman_luxury/test_init.py @@ -0,0 +1,48 @@ +"""Test the Harman Luxury integration setup.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from aioharmanluxury import HarmanLuxuryError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import DEVICE_INFO + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_client") +async def test_setup_and_unload( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test a config entry loads and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_cannot_connect( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry retries setup when the device is unreachable.""" + mock_client.async_get_info.side_effect = HarmanLuxuryError + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_unexpected_device( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test setup fails when the host answers as a different device.""" + mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="different") + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/harman_luxury/test_media_player.py b/tests/components/harman_luxury/test_media_player.py new file mode 100644 index 000000000000..e25fe28babc2 --- /dev/null +++ b/tests/components/harman_luxury/test_media_player.py @@ -0,0 +1,183 @@ +"""Test the Harman Luxury media player.""" + +from dataclasses import replace +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from aioharmanluxury import HarmanLuxuryError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + MediaPlayerEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import PLAYER_STATE + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "media_player.dining_room" + + +@pytest.mark.freeze_time("2024-01-01 12:00:00+00:00") +@pytest.mark.usefixtures("mock_client") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the media player entity state and attributes.""" + # Freeze the media-proxy token so the entity_picture URL is deterministic. + with patch("secrets.token_hex", return_value="deterministic"): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_volume_set( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test setting the volume level.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + mock_client.async_set_volume.assert_awaited_once_with(50) + + +async def test_volume_mute( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test muting the output.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + mock_client.async_set_mute.assert_awaited_once_with(True) + + +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_MEDIA_PAUSE, "pause"), + (SERVICE_MEDIA_STOP, "stop"), + (SERVICE_MEDIA_NEXT_TRACK, "next"), + (SERVICE_MEDIA_PREVIOUS_TRACK, "previous"), + ], +) +async def test_transport_commands( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: str, +) -> None: + """Test transport control services forward the right command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_client.async_control.assert_awaited_once_with(command) + + +async def test_command_error( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a failing device command raises a HomeAssistantError.""" + await setup_integration(hass, mock_config_entry) + mock_client.async_control.side_effect = HarmanLuxuryError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + +async def test_off_state( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the player reports off when the device is not online.""" + mock_client.async_get_state.return_value = replace(PLAYER_STATE, online=False) + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + +async def test_media_play_when_paused( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that play is available and forwarded when the source is paused.""" + mock_client.async_get_state.return_value = replace( + PLAYER_STATE, play_state="paused" + ) + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_client.async_control.assert_awaited_once_with("play") + + +async def test_transport_features_are_independent( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a source that only allows pause does not advertise play or stop.""" + mock_client.async_get_state.return_value = replace( + PLAYER_STATE, can_play=False, can_pause=True, can_stop=False + ) + await setup_integration(hass, mock_config_entry) + features = hass.states.get(ENTITY_ID).attributes[ATTR_SUPPORTED_FEATURES] + assert features & MediaPlayerEntityFeature.PAUSE + assert not features & MediaPlayerEntityFeature.PLAY + assert not features & MediaPlayerEntityFeature.STOP + + +async def test_becomes_unavailable_on_error( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the entity goes unavailable when polling fails.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + mock_client.async_get_state.side_effect = HarmanLuxuryError + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE From 6bf1f678d8a62f37ea3b420d9b43b6d3b6bb9d23 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:11:00 +0200 Subject: [PATCH 542/707] Use EntityStateAttribute enum in ISS (#176399) --- homeassistant/components/iss/sensor.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index 0241d1b4e160..980fa166e727 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -4,7 +4,7 @@ import logging from typing import Any, override from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP +from homeassistant.const import CONF_SHOW_ON_MAP, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -61,16 +61,13 @@ class IssSensor(CoordinatorEntity[IssDataUpdateCoordinator], SensorEntity): @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {} + attrs: dict[str, Any] = {} + location = self.coordinator.data.current_location if self._show_on_map: - attrs[ATTR_LONGITUDE] = self.coordinator.data.current_location.get( - "longitude" - ) - attrs[ATTR_LATITUDE] = self.coordinator.data.current_location.get( - "latitude" - ) + attrs[EntityStateAttribute.LONGITUDE] = location.get("longitude") + attrs[EntityStateAttribute.LATITUDE] = location.get("latitude") else: - attrs["long"] = self.coordinator.data.current_location.get("longitude") - attrs["lat"] = self.coordinator.data.current_location.get("latitude") + attrs["long"] = location.get("longitude") + attrs["lat"] = location.get("latitude") return attrs From 161b47a3d369fc3026e393a069f5ccbd685236d9 Mon Sep 17 00:00:00 2001 From: Manu Date: Mon, 13 Jul 2026 17:11:51 +0200 Subject: [PATCH 543/707] Add device class ENUM to account sensor in Steam integration (#176419) --- .../components/steam_online/sensor.py | 2 + .../steam_online/snapshots/test_sensor.ambr | 48 +++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 355cae888e44..0d8c4ba8f4bd 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -56,6 +56,8 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( key=SteamSensor.ACCOUNT, translation_key=SteamSensor.ACCOUNT, value_fn=lambda x: STEAM_STATUSES[x.personastate], + device_class=SensorDeviceClass.ENUM, + options=list(STEAM_STATUSES.values()), entity_picture_fn=lambda x, _: x.avatarfull, name=None, extra_state_attributes_fn=lambda x, icons: { diff --git a/tests/components/steam_online/snapshots/test_sensor.ambr b/tests/components/steam_online/snapshots/test_sensor.ambr index c06916174d98..cac8d8f57495 100644 --- a/tests/components/steam_online/snapshots/test_sensor.ambr +++ b/tests/components/steam_online/snapshots/test_sensor.ambr @@ -5,7 +5,17 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -24,7 +34,7 @@ 'object_id_base': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'steam_online', @@ -40,6 +50,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'created': datetime.datetime(2010, 5, 15, 12, 58, 31, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + : 'enum', : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', : 'testaccount1', 'game': 'The Witcher: Enhanced Edition', @@ -49,6 +60,15 @@ 'game_image_main': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/capsule_616x353.jpg', 'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc), 'level': 10, + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), 'real_name': 'John Dough', }), 'context': , @@ -221,7 +241,17 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -240,7 +270,7 @@ 'object_id_base': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'steam_online', @@ -256,6 +286,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'created': datetime.datetime(2011, 4, 19, 12, 57, 21, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + : 'enum', : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', : 'testaccount2', 'game': None, @@ -265,6 +296,15 @@ 'game_image_main': None, 'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc), 'level': 10, + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), 'real_name': None, }), 'context': , From 1299ea28ea2a9c8d74842cbfbb8256be8ee54881 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:13:03 +0200 Subject: [PATCH 544/707] Use EntityStateAttribute enum in Kiwi (#176400) --- homeassistant/components/kiwi/lock.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index c264f05f9916..4cd03a8e9653 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -13,10 +13,9 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ( ATTR_ID, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_PASSWORD, CONF_USERNAME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -68,12 +67,8 @@ class KiwiLock(LockEntity): self._state = LockState.LOCKED address = kiwi_lock.get("address") - address.update( - { - ATTR_LATITUDE: address.pop("lat", None), - ATTR_LONGITUDE: address.pop("lng", None), - } - ) + latitude = address.pop("lat", None) + longitude = address.pop("lng", None) self._device_attrs = { ATTR_ID: self.lock_id, @@ -81,6 +76,8 @@ class KiwiLock(LockEntity): ATTR_PERMISSION: kiwi_lock.get("highest_permission"), ATTR_CAN_INVITE: kiwi_lock.get("can_invite"), **address, + EntityStateAttribute.LATITUDE: latitude, + EntityStateAttribute.LONGITUDE: longitude, } @property From eff24301914873b1d3e73e1d9469951db3e67f81 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 13 Jul 2026 17:24:46 +0200 Subject: [PATCH 545/707] Add delay between commands for novy hood (#171717) --- .../components/novy_cooker_hood/fan.py | 29 ++++++++---- tests/components/novy_cooker_hood/conftest.py | 10 +++++ tests/components/novy_cooker_hood/test_fan.py | 45 +++++++++++++++---- .../components/novy_cooker_hood/test_light.py | 6 +-- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index 08782f7d7b95..eef061011034 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -1,8 +1,10 @@ """Fan platform for the Novy Cooker Hood (calibrated speed control).""" +import asyncio import math from typing import Any, override +from rf_protocols import RadioFrequencyCommand from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from homeassistant.components.fan import ( @@ -27,6 +29,11 @@ PARALLEL_UPDATES = 1 _SPEED_RANGE = (1, SPEED_COUNT) +# Minimum gap the hood needs to register consecutive presses as distinct +# button events. Without it, low-latency transmitters collapse rapid presses +# into a single one. +_COMMAND_DELAY = 0.5 + async def async_setup_entry( hass: HomeAssistant, @@ -114,8 +121,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): """Bump speed up by N hardware levels (no recalibration).""" steps = self._steps_from_percentage(percentage_step) plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) - for _ in range(steps): - await self._send_command(plus) + await self._async_send_repeated(plus, steps) self._level = min(SPEED_COUNT, self._level + steps) self.async_write_ha_state() @@ -124,8 +130,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): """Bump speed down by N hardware levels (no recalibration).""" steps = self._steps_from_percentage(percentage_step) minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) - for _ in range(steps): - await self._send_command(minus) + await self._async_send_repeated(minus, steps) self._level = max(0, self._level - steps) self.async_write_ha_state() @@ -139,11 +144,19 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): async def _async_set_level(self, level: int) -> None: """Reset to off with `SPEED_COUNT` minus presses, then climb to level.""" minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) - for _ in range(SPEED_COUNT): - await self._send_command(minus) + await self._async_send_repeated(minus, SPEED_COUNT) if level > 0: + await asyncio.sleep(_COMMAND_DELAY) plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) - for _ in range(level): - await self._send_command(plus) + await self._async_send_repeated(plus, level) self._level = level self.async_write_ha_state() + + async def _async_send_repeated( + self, command: RadioFrequencyCommand, count: int + ) -> None: + """Send the same RF command N times, pausing between presses.""" + for i in range(count): + if i > 0: + await asyncio.sleep(_COMMAND_DELAY) + await self._send_command(command) diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py index f9a0b62cf3b1..55f763bfe2fb 100644 --- a/tests/components/novy_cooker_hood/conftest.py +++ b/tests/components/novy_cooker_hood/conftest.py @@ -1,5 +1,8 @@ """Common fixtures for the Novy Cooker Hood tests.""" +from collections.abc import Iterator +from unittest.mock import patch + import pytest from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN @@ -13,6 +16,13 @@ from tests.components.radio_frequency.common import MockRadioFrequencyEntity TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" +@pytest.fixture(autouse=True) +def mock_command_delay() -> Iterator[None]: + """Drop the inter-command delay so tests don't spend real time waiting.""" + with patch("homeassistant.components.novy_cooker_hood.fan._COMMAND_DELAY", 0): + yield + + @pytest.fixture def mock_config_entry( mock_rf_entity: MockRadioFrequencyEntity, diff --git a/tests/components/novy_cooker_hood/test_fan.py b/tests/components/novy_cooker_hood/test_fan.py index 2a1c8590f931..46ec4301a1fd 100644 --- a/tests/components/novy_cooker_hood/test_fan.py +++ b/tests/components/novy_cooker_hood/test_fan.py @@ -1,5 +1,9 @@ """Tests for the Novy Hood fan platform.""" +from unittest.mock import AsyncMock, call, patch + +import pytest + from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PERCENTAGE_STEP, @@ -19,10 +23,10 @@ from tests.components.radio_frequency.common import MockRadioFrequencyEntity ENTITY_ID = "fan.novy_cooker_hood" +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_on_calibrates_to_level_1( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """Default turn_on sends 4 minus + 1 plus and lands at 25%.""" state = hass.states.get(ENTITY_ID) @@ -47,10 +51,10 @@ async def test_turn_on_calibrates_to_level_1( assert all(c.context is context for c in mock_rf_entity.send_command_calls) +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_on_with_percentage_calibrates_to_level( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """turn_on with percentage targets the matching level via calibration.""" await hass.services.async_call( @@ -67,10 +71,10 @@ async def test_turn_on_with_percentage_calibrates_to_level( assert len(mock_rf_entity.send_command_calls) == 6 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_set_percentage_zero_turns_off( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """set_percentage(0) turns the fan off via the calibration sequence.""" await hass.services.async_call( @@ -87,10 +91,10 @@ async def test_set_percentage_zero_turns_off( assert len(mock_rf_entity.send_command_calls) == 4 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_off_sends_four_minuses( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """turn_off sends 4 minus presses.""" await hass.services.async_call( @@ -107,10 +111,10 @@ async def test_turn_off_sends_four_minuses( assert len(mock_rf_entity.send_command_calls) == 4 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_set_percentage_calibrates( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """set_percentage(75) sends 4 minus + 3 plus and lands at level 3.""" await hass.services.async_call( @@ -127,10 +131,10 @@ async def test_set_percentage_calibrates( assert len(mock_rf_entity.send_command_calls) == 7 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_increase_speed_sends_single_plus( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """increase_speed sends one plus and bumps level by one (no recalibration).""" await hass.services.async_call( @@ -199,10 +203,10 @@ async def test_decrease_speed_sends_single_minus( assert len(mock_rf_entity.send_command_calls) == 1 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_increase_speed_with_step_sends_n_presses( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """increase_speed with percentage_step sends N plus presses (no recalibration).""" await hass.services.async_call( @@ -244,10 +248,10 @@ async def test_decrease_speed_with_step_sends_n_presses( assert len(mock_rf_entity.send_command_calls) == 2 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_decrease_speed_clamps_at_off( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """decrease_speed at level 0 still sends one minus but level stays at 0.""" await hass.services.async_call( @@ -263,6 +267,31 @@ async def test_decrease_speed_clamps_at_off( assert len(mock_rf_entity.send_command_calls) == 1 +@pytest.mark.usefixtures("init_novy_cooker_hood") +async def test_set_percentage_sleeps_between_presses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> None: + """A delay is awaited between every RF press, including between sequences.""" + delay = 0.5 + with ( + patch("homeassistant.components.novy_cooker_hood.fan._COMMAND_DELAY", delay), + patch( + "homeassistant.components.novy_cooker_hood.fan.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep, + ): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 75}, + blocking=True, + ) + + assert len(mock_rf_entity.send_command_calls) == 7 + assert mock_sleep.await_args_list == [call(delay)] * 6 + + async def test_restore_state( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py index 7a963611f320..13125e3dd98b 100644 --- a/tests/components/novy_cooker_hood/test_light.py +++ b/tests/components/novy_cooker_hood/test_light.py @@ -1,5 +1,6 @@ """Tests for the Novy Hood light platform.""" +import pytest from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from homeassistant.components.light import ( @@ -26,10 +27,10 @@ from tests.components.radio_frequency.common import MockRadioFrequencyEntity ENTITY_ID = "light.novy_cooker_hood_light" +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_on_and_off_send_light_once_each( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """Turn on sends a light toggle and flips is_on; turn off does the same.""" state = hass.states.get(ENTITY_ID) @@ -88,10 +89,9 @@ async def test_restore_state( assert state.state == STATE_ON +@pytest.mark.usefixtures("mock_rf_entity", "init_novy_cooker_hood") async def test_entity_follows_transmitter_availability( hass: HomeAssistant, - mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """The light becomes unavailable when the transmitter does, and back.""" await assert_availability_follows_source_entity( From fbad4e7df7b47c4d347d80bab2dddb99ce83ceef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:25:41 +0200 Subject: [PATCH 546/707] Use entity state attribute enums in Mobile App (#176407) --- homeassistant/components/mobile_app/device_tracker.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 189f8d95bf4a..ce6d1774bfc2 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -13,6 +13,7 @@ from homeassistant.components.device_tracker import ( ATTR_IN_ZONES, ATTR_LOCATION_NAME, TrackerEntity, + TrackerEntityStateAttribute, ) from homeassistant.components.zone import ( DOMAIN as ZONE_DOMAIN, @@ -24,9 +25,8 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, STATE_HOME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -221,8 +221,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): attr = state.attributes data = { - ATTR_GPS: (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), - ATTR_GPS_ACCURACY: attr.get(ATTR_GPS_ACCURACY), + ATTR_GPS: ( + attr.get(EntityStateAttribute.LATITUDE), + attr.get(EntityStateAttribute.LONGITUDE), + ), + ATTR_GPS_ACCURACY: attr.get(TrackerEntityStateAttribute.GPS_ACCURACY), ATTR_BATTERY: attr.get(ATTR_BATTERY_LEVEL), } data.update({key: attr[key] for key in attr if key in ATTR_KEYS}) From 33f723f26dd38961708465059b577a1296822782 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:26:39 +0200 Subject: [PATCH 547/707] Use EntityStateAttribute enum in GeoNet NZ Volcano (#176395) --- homeassistant/components/geonetnz_volcano/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 473a4ac0ad50..72f305d9f1e8 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -4,7 +4,7 @@ import logging from typing import Any, override from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength +from homeassistant.const import EntityStateAttribute, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -147,8 +147,8 @@ class GeonetnzVolcanoSensor(SensorEntity): (ATTR_EXTERNAL_ID, self._external_id), (ATTR_ACTIVITY, self._activity), (ATTR_HAZARDS, self._hazards), - (ATTR_LONGITUDE, self._longitude), - (ATTR_LATITUDE, self._latitude), + (EntityStateAttribute.LONGITUDE, self._longitude), + (EntityStateAttribute.LATITUDE, self._latitude), (ATTR_DISTANCE, self._distance), (ATTR_LAST_UPDATE, self._feed_last_update), (ATTR_LAST_UPDATE_SUCCESSFUL, self._feed_last_update_successful), From b1499eace9e7c39fe23c3fe55a3d906c7ecb9ecc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:26:49 +0200 Subject: [PATCH 548/707] Use EntityStateAttribute enum in Netatmo (#176406) --- homeassistant/components/netatmo/entity.py | 6 +- homeassistant/components/netatmo/sensor.py | 7 +- .../netatmo/snapshots/test_binary_sensor.ambr | 24 +- .../netatmo/snapshots/test_sensor.ambr | 324 +++++++++--------- 4 files changed, 180 insertions(+), 181 deletions(-) diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index c1f386b888dc..97a378c203ac 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -7,7 +7,7 @@ from pyatmo import DeviceType, Home, Module, Room from pyatmo.modules.base_class import NetatmoBase, Place from pyatmo.modules.device_types import DEVICE_DESCRIPTION_MAP -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -198,8 +198,8 @@ class NetatmoWeatherModuleEntity(NetatmoModuleEntity): if hasattr(place, "location") and place.location is not None: self._attr_extra_state_attributes.update( { - ATTR_LATITUDE: place.location.latitude, - ATTR_LONGITUDE: place.location.longitude, + EntityStateAttribute.LATITUDE: place.location.latitude, + EntityStateAttribute.LONGITUDE: place.location.longitude, } ) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index f53e29d203d2..b96292b245df 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -17,10 +17,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, DEGREE, EntityCategory, + EntityStateAttribute, UnitOfPower, UnitOfPrecipitationDepth, UnitOfPressure, @@ -939,8 +938,8 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): self._attr_extra_state_attributes.update( { - ATTR_LATITUDE: (area.lat_ne + area.lat_sw) / 2, - ATTR_LONGITUDE: (area.lon_ne + area.lon_sw) / 2, + EntityStateAttribute.LATITUDE: (area.lat_ne + area.lat_sw) / 2, + EntityStateAttribute.LONGITUDE: (area.lon_ne + area.lon_sw) / 2, } ) self._attr_device_info = DeviceInfo( diff --git a/tests/components/netatmo/snapshots/test_binary_sensor.ambr b/tests/components/netatmo/snapshots/test_binary_sensor.ambr index c0080ae96633..235a9c1e1d34 100644 --- a/tests/components/netatmo/snapshots/test_binary_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_binary_sensor.ambr @@ -42,8 +42,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Baby Bedroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.baby_bedroom_connectivity', @@ -96,8 +96,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Bedroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.bedroom_connectivity', @@ -150,8 +150,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Kitchen Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.kitchen_connectivity', @@ -204,8 +204,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Livingroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.livingroom_connectivity', @@ -258,8 +258,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Parents Bedroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.parents_bedroom_connectivity', @@ -416,8 +416,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Villa Connectivity', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'binary_sensor.villa_connectivity', diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 964e76a793cf..d9396f569900 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -50,8 +50,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Baby Bedroom Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -108,8 +108,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Baby Bedroom Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -172,8 +172,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Baby Bedroom Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -235,8 +235,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Baby Bedroom Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -296,8 +296,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Baby Bedroom Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -351,8 +351,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_pressure_trend', @@ -404,8 +404,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_reachability', @@ -463,8 +463,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Baby Bedroom Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -518,8 +518,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_temperature_trend', @@ -571,8 +571,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', @@ -975,8 +975,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Bedroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_reachability', @@ -1138,8 +1138,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Bedroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_wi_fi_strength', @@ -1575,8 +1575,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Home avg Atmospheric pressure', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1633,8 +1633,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home avg Gust angle', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -1694,8 +1694,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home avg Gust strength', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1752,8 +1752,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Home avg Humidity', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1813,8 +1813,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home avg Precipitation', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1874,8 +1874,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home avg Precipitation last hour', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1935,8 +1935,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home avg Precipitation today', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1996,8 +1996,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Home avg Temperature', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2054,8 +2054,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home avg Wind direction', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2115,8 +2115,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home avg Wind speed', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2179,8 +2179,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Home max Atmospheric pressure', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2237,8 +2237,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home max Gust angle', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2298,8 +2298,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home max Gust strength', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2356,8 +2356,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Home max Humidity', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2417,8 +2417,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home max Precipitation', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2478,8 +2478,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home max Precipitation last hour', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2539,8 +2539,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home max Precipitation today', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2600,8 +2600,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Home max Temperature', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2658,8 +2658,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home max Wind direction', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2719,8 +2719,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home max Wind speed', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2783,8 +2783,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Home min Atmospheric pressure', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2841,8 +2841,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home min Gust angle', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2902,8 +2902,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home min Gust strength', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2960,8 +2960,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Home min Humidity', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3021,8 +3021,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home min Precipitation', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3082,8 +3082,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home min Precipitation last hour', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3143,8 +3143,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home min Precipitation today', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3204,8 +3204,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Home min Temperature', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3262,8 +3262,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home min Wind direction', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -3323,8 +3323,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home min Wind speed', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3438,8 +3438,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Kitchen Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3496,8 +3496,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Kitchen Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3560,8 +3560,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Kitchen Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -3623,8 +3623,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Kitchen Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3684,8 +3684,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Kitchen Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3739,8 +3739,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_pressure_trend', @@ -3792,8 +3792,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_reachability', @@ -3851,8 +3851,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Kitchen Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3906,8 +3906,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_temperature_trend', @@ -3959,8 +3959,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_wi_fi_strength', @@ -4276,8 +4276,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Livingroom Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4390,8 +4390,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Livingroom Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4454,8 +4454,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Livingroom Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -4517,8 +4517,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Livingroom Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4578,8 +4578,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Livingroom Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4633,8 +4633,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_pressure_trend', @@ -4686,8 +4686,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_reachability', @@ -4745,8 +4745,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Livingroom Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4800,8 +4800,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_temperature_trend', @@ -4853,8 +4853,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_wi_fi_strength', @@ -4915,8 +4915,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Parents Bedroom Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4973,8 +4973,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Parents Bedroom Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5037,8 +5037,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Parents Bedroom Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -5100,8 +5100,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Parents Bedroom Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5161,8 +5161,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Parents Bedroom Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5216,8 +5216,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_pressure_trend', @@ -5269,8 +5269,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_reachability', @@ -5328,8 +5328,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Parents Bedroom Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5383,8 +5383,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_temperature_trend', @@ -5436,8 +5436,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', @@ -5771,8 +5771,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Villa Atmospheric pressure', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -6589,8 +6589,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Villa Carbon dioxide', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -7181,8 +7181,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Villa Humidity', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -7242,8 +7242,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Villa Noise', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -7621,8 +7621,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Pressure trend', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_pressure_trend', @@ -8009,8 +8009,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Reachability', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_reachability', @@ -8068,8 +8068,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Villa Temperature', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -8123,8 +8123,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Temperature trend', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_temperature_trend', @@ -8176,8 +8176,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Wi-Fi strength', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_wi_fi_strength', From 962d5904eb19cdfe883bfe1f57db6d60351c5b08 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 13 Jul 2026 17:27:24 +0200 Subject: [PATCH 549/707] Refactor binary sensors MELCloud Home (#176398) --- .../components/melcloud_home/binary_sensor.py | 32 +++--- .../snapshots/test_binary_sensor.ambr | 100 ++++++++++++++++++ 2 files changed, 116 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/melcloud_home/binary_sensor.py b/homeassistant/components/melcloud_home/binary_sensor.py index 1ed70ef82c76..bc27a1488093 100644 --- a/homeassistant/components/melcloud_home/binary_sensor.py +++ b/homeassistant/components/melcloud_home/binary_sensor.py @@ -57,27 +57,27 @@ def _common_sensor_descriptions[_UnitT: ATAUnit | ATWUnit]( ), entity_category=EntityCategory.DIAGNOSTIC, ), + MelCloudHomeBinarySensorEntityDescription( + key="frost_protection", + translation_key="frost_protection", + state_fn=lambda unit: ( + unit.frost_protection.enabled if unit.frost_protection else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + MelCloudHomeBinarySensorEntityDescription( + key="overheat_protection", + translation_key="overheat_protection", + state_fn=lambda unit: ( + unit.overheat_protection.enabled if unit.overheat_protection else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), ) ATA_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATAUnit], ...] = ( *_common_sensor_descriptions(ATAUnit), - MelCloudHomeBinarySensorEntityDescription( - key="frost_protection", - translation_key="frost_protection", - state_fn=lambda unit: ( - unit.frost_protection.enabled if unit.frost_protection else None - ), - entity_category=EntityCategory.DIAGNOSTIC, - ), - MelCloudHomeBinarySensorEntityDescription( - key="overheat_protection", - translation_key="overheat_protection", - state_fn=lambda unit: ( - unit.overheat_protection.enabled if unit.overheat_protection else None - ), - entity_category=EntityCategory.DIAGNOSTIC, - ), ) ATW_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATWUnit], ...] = ( diff --git a/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr b/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr index fc20fd067c7e..cec636627626 100644 --- a/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr @@ -100,6 +100,56 @@ 'state': 'off', }) # --- +# name: test_all_entities[binary_sensor.heat_pump_frost_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.heat_pump_frost_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Frost protection', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'atw-unit-uuid-1_frost_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.heat_pump_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Heat Pump Frost protection', + }), + 'context': , + 'entity_id': 'binary_sensor.heat_pump_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[binary_sensor.heat_pump_holiday_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -150,6 +200,56 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[binary_sensor.heat_pump_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.heat_pump_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Overheat protection', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection', + 'unique_id': 'atw-unit-uuid-1_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.heat_pump_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Heat Pump Overheat protection', + }), + 'context': , + 'entity_id': 'binary_sensor.heat_pump_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[binary_sensor.heat_pump_standby-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 656bd4b8e44b58d0761f4ea909f43acea39d812e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:27:42 +0200 Subject: [PATCH 550/707] Use EntityStateAttribute enum in PurpleAir (#176405) --- homeassistant/components/purpleair/entity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py index 02ae40b645de..c6a7b198a63d 100644 --- a/homeassistant/components/purpleair/entity.py +++ b/homeassistant/components/purpleair/entity.py @@ -5,7 +5,7 @@ from typing import Any, override from aiopurpleair.models.sensors import SensorModel -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP +from homeassistant.const import CONF_SHOW_ON_MAP, EntityStateAttribute from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -43,15 +43,15 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): @override def extra_state_attributes(self) -> Mapping[str, Any]: """Return entity specific state attributes.""" - attrs = {} + attrs: dict[str, Any] = {} # Displaying the geography on the map relies upon putting the latitude/longitude # in the entity attributes with "latitude" and "longitude" as the keys. # Conversely, we can hide the location on the map by using other keys, like # "lati" and "long": if self._entry.options.get(CONF_SHOW_ON_MAP): - attrs[ATTR_LATITUDE] = self.sensor_data.latitude - attrs[ATTR_LONGITUDE] = self.sensor_data.longitude + attrs[EntityStateAttribute.LATITUDE] = self.sensor_data.latitude + attrs[EntityStateAttribute.LONGITUDE] = self.sensor_data.longitude else: attrs["lati"] = self.sensor_data.latitude attrs["long"] = self.sensor_data.longitude From f7d809d00287772936037d1f8378f860b16a939d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:28:14 +0200 Subject: [PATCH 551/707] Use EntityStateAttribute enum in Luftdaten (#176401) --- homeassistant/components/luftdaten/sensor.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 04752d5493d0..6317b7e0e934 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -9,9 +9,8 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_SHOW_ON_MAP, + EntityStateAttribute, UnitOfDensity, UnitOfPressure, UnitOfRatio, @@ -120,12 +119,12 @@ class SensorCommunitySensor(CoordinatorEntity, SensorEntity): ) if show_on_map: - self._attr_extra_state_attributes[ATTR_LONGITUDE] = coordinator.data[ - "longitude" - ] - self._attr_extra_state_attributes[ATTR_LATITUDE] = coordinator.data[ - "latitude" - ] + self._attr_extra_state_attributes[EntityStateAttribute.LONGITUDE] = ( + coordinator.data["longitude"] + ) + self._attr_extra_state_attributes[EntityStateAttribute.LATITUDE] = ( + coordinator.data["latitude"] + ) @property @override From 98ee516fdeea696940a507b0ce1b33e07e3f9a73 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:28:41 +0200 Subject: [PATCH 552/707] Use EntityStateAttribute enum in NMBS (#176402) --- homeassistant/components/nmbs/sensor.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 8e7cdc298eba..e38b7de4af11 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -10,10 +10,9 @@ from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP, + EntityStateAttribute, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -275,8 +274,8 @@ class NMBSSensor(SensorEntity): attrs["departure_minutes"] = departure if self._show_on_map and self.station_coordinates: - attrs[ATTR_LATITUDE] = self.station_coordinates[0] - attrs[ATTR_LONGITUDE] = self.station_coordinates[1] + attrs[EntityStateAttribute.LATITUDE] = self.station_coordinates[0] + attrs[EntityStateAttribute.LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: via = self._attrs.vias[0] From 98a115abafad8b14febfb2a1c48c636416131d09 Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:29:11 +0200 Subject: [PATCH 553/707] Refresh Lunatone sensor data before update (#176389) --- homeassistant/components/lunatone/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py index 52b48c8ca5fb..f5a591e5f296 100644 --- a/homeassistant/components/lunatone/coordinator.py +++ b/homeassistant/components/lunatone/coordinator.py @@ -139,6 +139,7 @@ class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Senso async def _async_update_data(self) -> dict[int, Sensor]: """Update sensor data.""" try: + await self.sensors_api.async_refresh() await self.sensors_api.async_update() except aiohttp.ClientConnectionError as ex: raise UpdateFailed( From 90f0afded87ca18f128ff2e5b33ebc4fe7b04747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Mon, 13 Jul 2026 17:31:04 +0200 Subject: [PATCH 554/707] Add reconfigure flow to nobo_hub (#169493) --- .../components/nobo_hub/config_flow.py | 69 +++++++- .../components/nobo_hub/quality_scale.yaml | 2 +- .../components/nobo_hub/strings.json | 14 +- tests/components/nobo_hub/test_config_flow.py | 160 ++++++++++++++++++ 4 files changed, 239 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 58a6c90e2696..8df9940493d3 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -1,12 +1,13 @@ """Config flow for Nobø Ecohub integration.""" -import socket +import ipaddress from typing import TYPE_CHECKING, Any, override from pynobo import nobo import voluptuous as vol from homeassistant.config_entries import ( + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -199,6 +200,68 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing hub. + + Only the IP address is editable. When the entry is not loaded, + the new IP is probed here before updating. When the entry is + loaded, probing is skipped to avoid competing with the active + connection for the hub's limited concurrent-connection slots; + the reload's ``async_setup_entry`` re-validates the updated IP. + """ + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + new_ip = user_input[CONF_IP_ADDRESS] + is_loaded = reconfigure_entry.state is ConfigEntryState.LOADED + try: + ipaddress.ip_address(new_ip) + except ValueError: + errors[CONF_IP_ADDRESS] = "invalid_ip" + else: + try: + # Probe the new IP only when the integration is not currently + # loaded — if it were, the running connection would compete + # with the probe for the hub's limited concurrent-connection + # slots. + if not is_loaded: + await self._test_connection( + reconfigure_entry.data[CONF_SERIAL], new_ip + ) + except NoboHubConnectError as error: + # The serial is fixed in reconfigure, so blame the IP rather + # than the (uneditable) serial number. + errors[CONF_IP_ADDRESS] = ( + "cannot_connect_ip" + if error.msg == "cannot_connect" + else error.msg + ) + else: + if new_ip == reconfigure_entry.data[CONF_IP_ADDRESS] and is_loaded: + # No-op: IP unchanged and the running integration already + # proves it works. Skip the reload to avoid a needless + # reconnect. + return self.async_abort(reason="reconfigure_successful") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_IP_ADDRESS: new_ip}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_IP_ADDRESS): str}), + user_input or reconfigure_entry.data, + ), + errors=errors, + description_placeholders={ + CONF_SERIAL: reconfigure_entry.data[CONF_SERIAL], + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -245,8 +308,8 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): if len(serial) != SERIAL_LENGTH or not serial.isdigit(): raise NoboHubConnectError("invalid_serial") try: - socket.inet_aton(ip_address) - except OSError as err: + ipaddress.ip_address(ip_address) + except ValueError as err: raise NoboHubConnectError("invalid_ip") from err hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) # pynobo distinguishes the two failure modes: TCP-level errors diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 8855f9f95240..1812cab9b10f 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -65,7 +65,7 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration has no repair scenarios. diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 07cd3d15c269..4725b8cb05d8 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address." + "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "Failed to connect - check serial number", @@ -24,6 +25,15 @@ }, "description": "Configure a Nobø Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address." }, + "reconfigure": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "[%key:component::nobo_hub::config::step::manual::data_description::ip_address%]" + }, + "description": "Update the IP address for Nobø Ecohub with serial number {serial}." + }, "selected": { "data": { "serial_suffix": "Serial number suffix (3 digits)" @@ -62,7 +72,7 @@ }, "exceptions": { "cannot_connect": { - "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, remove and re-add the integration." + "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, reconfigure the integration with the new IP address." }, "set_global_override_failed": { "message": "Failed to set global override." diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py index d8155df92244..a62e84bb0a92 100644 --- a/tests/components/nobo_hub/test_config_flow.py +++ b/tests/components/nobo_hub/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Nobø Ecohub config flow.""" +import errno from unittest.mock import AsyncMock, PropertyMock, patch import pytest @@ -15,6 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .conftest import SERIAL, STORED_IP + from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( @@ -773,6 +776,163 @@ async def test_dhcp_discovery_no_broadcast(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_discover" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow_changes_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A new IP is probed before save when the entry is not loaded.""" + new_ip = "192.168.1.200" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["description_placeholders"] == {CONF_SERIAL: SERIAL} + + with ( + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + return_value=True, + ) as mock_connect, + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: new_ip}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: new_ip} + mock_connect.assert_awaited_once_with(new_ip, SERIAL) + + +@pytest.mark.parametrize( + ("submitted_ip", "connect_outcome", "expected_error", "expected_connect_count"), + [ + ( + "192.168.1.200", + {"side_effect": ConnectionRefusedError(errno.ECONNREFUSED, "")}, + "cannot_connect_ip", + 1, + ), + ("not-an-ip", {"return_value": True}, "invalid_ip", 0), + ], + ids=["unreachable_ip", "invalid_format"], +) +@pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry") +async def test_reconfigure_flow_rejects_bad_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + submitted_ip: str, + connect_outcome: dict[str, object], + expected_error: str, + expected_connect_count: int, +) -> None: + """A bad IP is rejected inline; resubmitting a good IP completes the reconfigure.""" + recovery_ip = "192.168.1.201" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + **connect_outcome, + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: submitted_ip}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_IP_ADDRESS: expected_error} + assert mock_config_entry.data[CONF_IP_ADDRESS] == STORED_IP + assert mock_connect.await_count == expected_connect_count + + with ( + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + return_value=True, + ), + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_IP_ADDRESS: recovery_ip}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: recovery_ip} + + +async def test_reconfigure_flow_unchanged_ip_skips_reload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, +) -> None: + """Submitting the same IP while connected aborts without triggering a reload.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_setup_entry.reset_mock() + + result = await mock_config_entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: STORED_IP}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + mock_unload_entry.assert_not_awaited() + mock_setup_entry.assert_not_awaited() + + +async def test_reconfigure_flow_changed_ip_triggers_reload_and_skips_probe( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, +) -> None: + """Submitting a different IP while connected reloads the entry without probing.""" + new_ip = "192.168.1.200" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_setup_entry.reset_mock() + mock_unload_entry.reset_mock() + + result = await mock_config_entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub" + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: new_ip}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_IP_ADDRESS] == new_ip + mock_connect.assert_not_awaited() + mock_unload_entry.assert_awaited_once() + mock_setup_entry.assert_awaited_once() + + async def test_options_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, From 431ed2b5a1a0a080fdaf33bd5c5b55709323e48b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:32:23 +0200 Subject: [PATCH 555/707] Use EntityStateAttribute enum in PEGELONLINE (#176403) --- homeassistant/components/pegel_online/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index e88e3f885cc4..4fe461f27e5b 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -131,8 +131,8 @@ class PegelOnlineSensor(PegelOnlineEntity, SensorEntity): if self.station.latitude and self.station.longitude: self._attr_extra_state_attributes.update( { - ATTR_LATITUDE: self.station.latitude, - ATTR_LONGITUDE: self.station.longitude, + EntityStateAttribute.LATITUDE: self.station.latitude, + EntityStateAttribute.LONGITUDE: self.station.longitude, } ) From 79b6c3b19a1fb4bceb4b1770ee3db13e89ae57a0 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 13 Jul 2026 17:34:54 +0200 Subject: [PATCH 556/707] Add boost and drying duration numbers for Atlantic Electrical Towel Dryer in Overkiz (#176291) --- homeassistant/components/overkiz/number.py | 31 + .../setup/cloud_atlantic_cozytouch.json | 1663 +++++++++++++++++ .../overkiz/snapshots/test_binary_sensor.ambr | 51 + .../overkiz/snapshots/test_climate.ambr | 81 + .../overkiz/snapshots/test_number.ambr | 248 +++ .../overkiz/snapshots/test_sensor.ambr | 223 +++ tests/components/overkiz/test_number.py | 47 +- 7 files changed, 2335 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 8307b1e8f13d..81e7de6ae857 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -11,6 +11,7 @@ from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, + NumberMode, ) from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant @@ -186,6 +187,36 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ device_class=NumberDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, ), + # AtlanticElectricalTowelDryer - boost mode duration in minutes + OverkizNumberDescription( + key=OverkizState.IO_BOOST_DURATION_USER_PARAMETER, + name="Boost mode duration", + icon="mdi:radiator", + command=OverkizCommand.SET_TOWEL_DRYER_BOOST_MODE_DURATION, + native_min_value=0, + native_max_value=60, + native_step=1, + mode=NumberMode.BOX, + max_value_state_name=OverkizState.IO_BOOST_DURATION_MAX, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), + # AtlanticElectricalTowelDryer - drying duration in minutes (0 - 120) + OverkizNumberDescription( + key=OverkizState.IO_DRYING_DURATION_USER_PARAMETER, + name="Drying duration", + icon="mdi:tumble-dryer", + command=OverkizCommand.SET_DRYING_DURATION, + native_min_value=0, + native_max_value=120, + native_step=1, + mode=NumberMode.BOX, + max_value_state_name=OverkizState.IO_DRYING_DURATION_MAX, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), ] SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCRIPTIONS} diff --git a/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json b/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json index fc13e28ff9ff..1b530de81464 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json +++ b/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json @@ -2242,6 +2242,1669 @@ "widget": "TemperatureSensor", "type": 2, "uiClass": "TemperatureSensor" + }, + { + "creationTime": 1740209632000, + "lastUpdateTime": 1740209632000, + "label": "Bathroom Towel Dryer", + "deviceURL": "io://1234-5678-5643/5237136#1", + "shortcut": false, + "controllableName": "io:AtlanticElectricalTowelDryer_IC3_IOComponent", + "definition": { + "commands": [ + { + "commandName": "addLockLevel", + "nparams": 2 + }, + { + "commandName": "advancedRefresh", + "nparams": 2 + }, + { + "commandName": "cancelHeatingLevel", + "nparams": 1 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "off", + "nparams": 0 + }, + { + "commandName": "refreshComfortTemperature", + "nparams": 0 + }, + { + "commandName": "refreshDateTime", + "nparams": 0 + }, + { + "commandName": "refreshDerogatedTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshEcoTemperature", + "nparams": 0 + }, + { + "commandName": "refreshHeatingLevel", + "nparams": 0 + }, + { + "commandName": "refreshManufacturerName", + "nparams": 0 + }, + { + "commandName": "refreshMaximumHeatingTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshMaximumTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshOperatingMode", + "nparams": 0 + }, + { + "commandName": "refreshTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshTemperature", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1 + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setComfortTemperature", + "nparams": 1 + }, + { + "commandName": "setDateTime", + "nparams": 1 + }, + { + "commandName": "setDerogatedTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setEcoTemperature", + "nparams": 1 + }, + { + "commandName": "setHeatingLevel", + "nparams": 1 + }, + { + "commandName": "setHeatingLevelWithTimer", + "nparams": 2 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "setPreviousTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setSchedulingType", + "nparams": 1 + }, + { + "commandName": "setTargetTemperature", + "nparams": 1 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "pairOneWayController", + "nparams": 2 + }, + { + "commandName": "refreshAutoProgram", + "nparams": 0 + }, + { + "commandName": "refreshBoostModeDuration", + "nparams": 0 + }, + { + "commandName": "refreshBoostModeParameters", + "nparams": 0 + }, + { + "commandName": "refreshControllerAddress", + "nparams": 0 + }, + { + "commandName": "refreshCumulatedLowering", + "nparams": 0 + }, + { + "commandName": "refreshCurrentWorkingRate", + "nparams": 0 + }, + { + "commandName": "refreshDeletionCancelation", + "nparams": 0 + }, + { + "commandName": "refreshDryingDuration", + "nparams": 0 + }, + { + "commandName": "refreshDryingParameters", + "nparams": 0 + }, + { + "commandName": "refreshEffectiveTemperatureSetpoint", + "nparams": 0 + }, + { + "commandName": "refreshLocalLeadTime", + "nparams": 0 + }, + { + "commandName": "refreshModel", + "nparams": 0 + }, + { + "commandName": "refreshNativeFunctionalLevel", + "nparams": 0 + }, + { + "commandName": "refreshOccupancy", + "nparams": 0 + }, + { + "commandName": "refreshPeakNotice", + "nparams": 0 + }, + { + "commandName": "refreshPeakWarning", + "nparams": 0 + }, + { + "commandName": "refreshPowerAndTension", + "nparams": 0 + }, + { + "commandName": "refreshRoomDeletionThreshold", + "nparams": 0 + }, + { + "commandName": "refreshSSVError6", + "nparams": 0 + }, + { + "commandName": "refreshSetpointLoweringTemperatureInProgMode", + "nparams": 0 + }, + { + "commandName": "refreshSynchronisationRequest", + "nparams": 0 + }, + { + "commandName": "refreshTemperatureProbeCalibrationOffset", + "nparams": 0 + }, + { + "commandName": "refreshTowelDryerTemporaryState", + "nparams": 0 + }, + { + "commandName": "refreshTowelDryerTimeProgram", + "nparams": 0 + }, + { + "commandName": "setCommunicationTest", + "nparams": 1 + }, + { + "commandName": "setDeletionCancelation", + "nparams": 1 + }, + { + "commandName": "setDryingDuration", + "nparams": 1 + }, + { + "commandName": "setExpectedPresence", + "nparams": 1 + }, + { + "commandName": "setPeakNotice", + "nparams": 1 + }, + { + "commandName": "setPeakWarning", + "nparams": 1 + }, + { + "commandName": "setRoomDeletionThreshold", + "nparams": 1 + }, + { + "commandName": "setSetpointLoweringTemperatureInProgMode", + "nparams": 1 + }, + { + "commandName": "setTemperatureProbeCalibrationOffset", + "nparams": 1 + }, + { + "commandName": "setTowelDryerBoostModeDuration", + "nparams": 1 + }, + { + "commandName": "setTowelDryerOperatingMode", + "nparams": 1 + }, + { + "commandName": "setTowelDryerTemporaryState", + "nparams": 1 + }, + { + "commandName": "setTowelDryerTimeProgram", + "nparams": 1 + }, + { + "commandName": "unpairAllOneWayControllers", + "nparams": 0 + }, + { + "commandName": "unpairOneWayController", + "nparams": 2 + } + ], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:BoostModeDurationState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ComfortRoomTemperatureState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:CommandLockLevelsState" + }, + { + "type": "DataState", + "qualifiedName": "core:DateTimeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:DerogatedTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:EcoRoomTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:FilterFanCloggingErrorState" + }, + { + "type": "DataState", + "qualifiedName": "core:IdentifierState" + }, + { + "type": "DataState", + "qualifiedName": "core:ManufacturerNameState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:MaximumHeatingTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:MaximumTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["noPersonInside", "personInside"], + "qualifiedName": "core:OccupancyState" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "core:OnOffState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:PreviousTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:PriorityLockTimerState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["increase", "none", "standby"], + "qualifiedName": "core:RegulationModeState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:VersionState" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "core:WinterModeState" + }, + { + "type": "DataState", + "qualifiedName": "io:AutoProgramState" + }, + { + "type": "DataState", + "qualifiedName": "io:BoostDurationMaxState" + }, + { + "type": "DataState", + "qualifiedName": "io:BoostDurationUserParameterState" + }, + { + "type": "DataState", + "qualifiedName": "io:ControllerAddressState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:CumulatedLoweringState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:CurrentWorkingRateState" + }, + { + "type": "DiscreteState", + "values": ["deletion cancelation", "no deletion cancelation"], + "qualifiedName": "io:DeletionCancelationState" + }, + { + "type": "DataState", + "qualifiedName": "io:DryingDurationMaxState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:DryingDurationState" + }, + { + "type": "DataState", + "qualifiedName": "io:DryingDurationUserParameterState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:EffectiveTemperatureSetpointState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:ExpectedPresenceState" + }, + { + "type": "DiscreteState", + "values": ["external", "internal"], + "qualifiedName": "io:InternalExternalSchedulingTypeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:LocalLeadTimeState" + }, + { + "type": "DiscreteState", + "values": [ + "boost", + "comfort", + "comfort-1", + "comfort-2", + "eco", + "frostprotection", + "off", + "secured" + ], + "qualifiedName": "io:MaximumHeatingLevelState" + }, + { + "type": "DataState", + "qualifiedName": "io:ModelState" + }, + { + "type": "DiscreteState", + "values": ["base", "medium", "top"], + "qualifiedName": "io:NativeFunctionalLevelState" + }, + { + "type": "DiscreteState", + "values": ["long peak", "no peak", "short peak"], + "qualifiedName": "io:PeakNoticeState" + }, + { + "type": "DiscreteState", + "values": ["long peak warning", "no warning", "short peak warning"], + "qualifiedName": "io:PeakWarningState" + }, + { + "type": "DataState", + "qualifiedName": "io:PowerState" + }, + { + "type": "DiscreteState", + "values": [ + "comfortLevel1", + "comfortLevel2", + "comfortLevel3", + "comfortLevel4", + "environmentProtection", + "humanProtection", + "userLevel1", + "userLevel2" + ], + "qualifiedName": "io:PriorityLockLevelState" + }, + { + "type": "DiscreteState", + "values": [ + "LSC", + "SAAC", + "SFC", + "UPS", + "externalGateway", + "localUser", + "myself", + "rain", + "security", + "temperature", + "timer", + "user", + "wind" + ], + "qualifiedName": "io:PriorityLockOriginatorState" + }, + { + "type": "DataState", + "qualifiedName": "io:RoomDeletionThresholdState" + }, + { + "type": "DiscreteState", + "values": ["kept", "lost"], + "qualifiedName": "io:RunningState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:SetpointLoweringTemperatureInProgModeState" + }, + { + "type": "DiscreteState", + "values": [ + "boost", + "comfort", + "comfort-1", + "comfort-2", + "eco", + "frostprotection", + "off", + "secured" + ], + "qualifiedName": "io:TargetHeatingLevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:TemperatureProbeCalibrationOffsetState" + }, + { + "type": "DataState", + "qualifiedName": "io:TensionState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:TimerForTransitoryStateState" + }, + { + "type": "DiscreteState", + "values": ["boost", "drying", "permanentHeating"], + "qualifiedName": "io:TowelDryerTemporaryStateState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:UptimeState" + }, + { + "type": "DataState", + "qualifiedName": "io:WinterModeSupportedState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "AtlanticElectricalTowelDryer", + "uiProfiles": [ + "HeatingLevel", + "StatefulThermostat", + "Thermostat", + "OccupancyDetector" + ], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "io:AtlanticElectricalTowelDryer_IC3_IOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "*" + }, + { + "name": "core:VersionState", + "type": 3, + "value": "45373235303038202020" + }, + { + "name": "core:CommandLockLevelsState", + "type": 3, + "value": "[]", + "lastUpdateTime": 1773159693000 + }, + { + "name": "core:OnOffState", + "type": 3, + "value": "on" + }, + { + "name": "io:TargetHeatingLevelState", + "type": 3, + "value": "comfort" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "normal" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 74.0 + }, + { + "name": "core:IdentifierState", + "type": 3, + "value": "00000000" + }, + { + "name": "io:MaximumHeatingLevelState", + "type": 3, + "value": "unknown" + }, + { + "name": "io:TimerForTransitoryStateState", + "type": 1, + "value": 0 + }, + { + "name": "core:ComfortRoomTemperatureState", + "type": 1, + "value": 7 + }, + { + "name": "core:EcoRoomTemperatureState", + "type": 1, + "value": 3 + }, + { + "name": "io:SetpointLoweringTemperatureInProgModeState", + "type": 2, + "value": 3.0 + }, + { + "name": "io:InternalExternalSchedulingTypeState", + "type": 3, + "value": "external" + }, + { + "name": "core:DateTimeState", + "type": 11, + "value": { + "month": 7, + "hour": 11, + "year": 2026, + "weekday": 3, + "day": 2, + "minute": 20, + "second": 58 + } + }, + { + "name": "io:LocalLeadTimeState", + "type": 1, + "value": 3549 + }, + { + "name": "core:RegulationModeState", + "type": 3, + "value": "none" + }, + { + "name": "core:ManufacturerNameState", + "type": 3, + "value": "Sauter" + }, + { + "name": "io:ModelState", + "type": 3, + "value": "ASAMA" + }, + { + "name": "io:PowerState", + "type": 1, + "value": 500 + }, + { + "name": "io:TensionState", + "type": 1, + "value": 230 + }, + { + "name": "core:DerogatedTargetTemperatureState", + "type": 2, + "value": 0.0 + }, + { + "name": "io:NativeFunctionalLevelState", + "type": 3, + "value": "Base" + }, + { + "name": "io:TemperatureProbeCalibrationOffsetState", + "type": 2, + "value": 0.0 + }, + { + "name": "io:CumulatedLoweringState", + "type": 2, + "value": 0.0 + }, + { + "name": "io:ControllerAddressState", + "type": 1, + "value": 8926985 + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "external" + }, + { + "name": "io:EffectiveTemperatureSetpointState", + "type": 2, + "value": 0.2 + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 7.0 + }, + { + "name": "core:BoostModeDurationState", + "type": 1, + "value": 0 + }, + { + "name": "io:BoostDurationUserParameterState", + "type": 1, + "value": 35 + }, + { + "name": "io:BoostDurationMaxState", + "type": 1, + "value": 60 + }, + { + "name": "io:DryingDurationState", + "type": 1, + "value": 27 + }, + { + "name": "io:DryingDurationUserParameterState", + "type": 1, + "value": 60 + }, + { + "name": "io:DryingDurationMaxState", + "type": 1, + "value": 120 + }, + { + "name": "io:TowelDryerTemporaryStateState", + "type": 3, + "value": "drying" + }, + { + "name": "core:FilterFanCloggingErrorState", + "type": 2, + "value": 60.0 + }, + { + "name": "io:AutoProgramState", + "type": 11, + "value": { + "sunday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "saturday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "tuesday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "wednesday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "thursday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "friday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "anticipTime": 3549, + "anticipNb": 20, + "monday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ] + } + }, + { + "name": "core:TimeProgramState", + "type": 10, + "value": [ + { + "monday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "tuesday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "wednesday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "thursday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "friday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "saturday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "sunday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + ] + }, + { + "name": "io:CurrentWorkingRateState", + "type": 2, + "value": 100.0 + } + ], + "attributes": [ + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "E725008" + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Atlantic Group" + } + ], + "available": true, + "enabled": true, + "placeOID": "61435d6a-4ba6-4c96-b7b5-105bb31d3b87", + "widget": "AtlanticElectricalTowelDryer", + "type": 1, + "oid": "260c6316-213c-45ee-9c38-46850a698bf2", + "uiClass": "HeatingSystem" } ], "features": [], diff --git a/tests/components/overkiz/snapshots/test_binary_sensor.ambr b/tests/components/overkiz/snapshots/test_binary_sensor.ambr index 6052a5bb0794..b3b9e08a0490 100644 --- a/tests/components/overkiz/snapshots/test_binary_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_binary_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_binary_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][binary_sensor.my_home_bathroom_towel_dryer_occupancy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_home_bathroom_towel_dryer_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Occupancy', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:OccupancyState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][binary_sensor.my_home_bathroom_towel_dryer_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'occupancy', + : 'Bathroom Towel Dryer Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.my_home_bathroom_towel_dryer_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][binary_sensor.my_home_patio_water_heating_energy_demand_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/snapshots/test_climate.ambr b/tests/components/overkiz/snapshots/test_climate.ambr index 0e37f35b9f6b..cef1c9b44662 100644 --- a/tests/components/overkiz/snapshots/test_climate.ambr +++ b/tests/components/overkiz/snapshots/test_climate.ambr @@ -93,6 +93,87 @@ 'state': 'heat', }) # --- +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.my_home_bathroom_towel_dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + ]), + : 35, + : 7, + : list([ + 'none', + 'prog', + 'boost', + 'drying', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.my_home_bathroom_towel_dryer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-5643/5237136#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.my_home_bathroom_towel_dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'Bathroom Towel Dryer', + : list([ + , + , + , + ]), + : 35, + : 7, + : 'drying', + : list([ + 'none', + 'prog', + 'boost', + 'drying', + ]), + : , + : 7.0, + }), + 'context': , + 'entity_id': 'climate.my_home_bathroom_towel_dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/snapshots/test_number.ambr b/tests/components/overkiz/snapshots/test_number.ambr index 60b109664adb..fc922663a26b 100644 --- a/tests/components/overkiz/snapshots/test_number.ambr +++ b/tests/components/overkiz/snapshots/test_number.ambr @@ -1,4 +1,252 @@ # serializer version: 1 +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_boost_mode_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_boost_mode_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boost mode duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Boost mode duration', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-io:BoostDurationUserParameterState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_boost_mode_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bathroom Towel Dryer Boost mode duration', + : 'mdi:radiator', + : 60, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_boost_mode_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_comfort_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 7, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_comfort_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Comfort room temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:home-thermometer-outline', + 'original_name': 'Comfort room temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:ComfortRoomTemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_comfort_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Bathroom Towel Dryer Comfort room temperature', + : 'mdi:home-thermometer-outline', + : 30, + : 7, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_comfort_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_drying_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 120, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_drying_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Drying duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:tumble-dryer', + 'original_name': 'Drying duration', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-io:DryingDurationUserParameterState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_drying_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bathroom Towel Dryer Drying duration', + : 'mdi:tumble-dryer', + : 120, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_drying_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_eco_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 29, + : 6, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_eco_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Eco room temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer', + 'original_name': 'Eco room temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:EcoRoomTemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_eco_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Bathroom Towel Dryer Eco room temperature', + : 'mdi:thermometer', + : 29, + : 6, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_eco_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_away_mode_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/snapshots/test_sensor.ambr b/tests/components/overkiz/snapshots/test_sensor.ambr index 81ec3e71ff7f..6d75dfd7d337 100644 --- a/tests/components/overkiz/snapshots/test_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_sensor.ambr @@ -57,6 +57,229 @@ 'state': '21.1', }) # --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_discrete_rssi_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'verylow', + 'low', + 'normal', + 'good', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_discrete_rssi_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discrete RSSI level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:wifi', + 'original_name': 'Discrete RSSI level', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discrete_rssi_level', + 'unique_id': 'io://1234-5678-5643/5237136#1-core:DiscreteRSSILevelState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_discrete_rssi_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Bathroom Towel Dryer Discrete RSSI level', + : 'mdi:wifi', + : list([ + 'verylow', + 'low', + 'normal', + 'good', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_discrete_rssi_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_originator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_priority_lock_originator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority lock originator', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock', + 'original_name': 'Priority lock originator', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'priority_lock_originator', + 'unique_id': 'io://1234-5678-5643/5237136#1-io:PriorityLockOriginatorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_originator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bathroom Towel Dryer Priority lock originator', + : 'mdi:lock', + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_priority_lock_originator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_priority_lock_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority lock timer', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-clock', + 'original_name': 'Priority lock timer', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:PriorityLockTimerState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bathroom Towel Dryer Priority lock timer', + : 'mdi:lock-clock', + : , + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_priority_lock_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_rssi_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_rssi_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RSSI level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI level', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:RSSILevelState', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_rssi_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'Bathroom Towel Dryer RSSI level', + : , + : 'dB', + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_rssi_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74', + }) +# --- # name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_patio_water_heating_bottom_tank_water_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_number.py b/tests/components/overkiz/test_number.py index 8d570920bc10..45d06b5356fb 100644 --- a/tests/components/overkiz/test_number.py +++ b/tests/components/overkiz/test_number.py @@ -48,6 +48,16 @@ COMFORT_ROOM_TEMPERATURE = FixtureDevice( "ovp://1234-5678-1698/374762#1", "number.maple_residence_terrace_radiator_comfort_room_temperature", ) +TOWEL_DRYER_BOOST_MODE_DURATION = FixtureDevice( + "setup/cloud_atlantic_cozytouch.json", + "io://1234-5678-5643/5237136#1", + "number.my_home_bathroom_towel_dryer_boost_mode_duration", +) +TOWEL_DRYER_DRYING_DURATION = FixtureDevice( + "setup/cloud_atlantic_cozytouch.json", + "io://1234-5678-5643/5237136#1", + "number.my_home_bathroom_towel_dryer_drying_duration", +) SNAPSHOT_FIXTURES = [ MEMORIZED_POSITION, @@ -83,30 +93,49 @@ async def test_number_entities_snapshot( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize( + ("device", "value", "command_name"), + [ + pytest.param( + EXPECTED_NUMBER_OF_SHOWER, 3, "setExpectedNumberOfShower", id="shower" + ), + pytest.param( + TOWEL_DRYER_BOOST_MODE_DURATION, + 45, + "setTowelDryerBoostModeDuration", + id="towel_dryer_boost_mode_duration", + ), + pytest.param( + TOWEL_DRYER_DRYING_DURATION, + 90, + "setDryingDuration", + id="towel_dryer_drying_duration", + ), + ], +) async def test_number_set_value( hass: HomeAssistant, setup_overkiz_integration: SetupOverkizIntegration, mock_client: MockOverkizClient, + device: FixtureDevice, + value: int, + command_name: str, ) -> None: """Test setting a number value sends the correct command.""" - await setup_overkiz_integration(fixture=EXPECTED_NUMBER_OF_SHOWER.fixture) - - state = hass.states.get(EXPECTED_NUMBER_OF_SHOWER.entity_id) - assert state - assert state.state == "4" + await setup_overkiz_integration(fixture=device.fixture) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: EXPECTED_NUMBER_OF_SHOWER.entity_id, ATTR_VALUE: 3}, + {ATTR_ENTITY_ID: device.entity_id, ATTR_VALUE: value}, blocking=True, ) assert_command_call( mock_client, - device_url=EXPECTED_NUMBER_OF_SHOWER.device_url, - command_name="setExpectedNumberOfShower", - parameters=[3], + device_url=device.device_url, + command_name=command_name, + parameters=[value], ) From eead15784ea10af362fca089d1111b1c8413774b Mon Sep 17 00:00:00 2001 From: Flo <57716204+sli-cka@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:38:53 +0200 Subject: [PATCH 557/707] Add karakeep integration (#174299) --- CODEOWNERS | 2 + homeassistant/components/karakeep/__init__.py | 33 ++ .../components/karakeep/config_flow.py | 104 ++++++ homeassistant/components/karakeep/const.py | 12 + .../components/karakeep/coordinator.py | 67 ++++ homeassistant/components/karakeep/entity.py | 26 ++ homeassistant/components/karakeep/icons.json | 24 ++ .../components/karakeep/manifest.json | 11 + .../components/karakeep/quality_scale.yaml | 84 +++++ homeassistant/components/karakeep/sensor.py | 100 ++++++ .../components/karakeep/strings.json | 57 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/karakeep/__init__.py | 13 + tests/components/karakeep/conftest.py | 57 +++ tests/components/karakeep/const.py | 16 + .../karakeep/snapshots/test_sensor.ambr | 325 ++++++++++++++++++ tests/components/karakeep/test_config_flow.py | 163 +++++++++ tests/components/karakeep/test_init.py | 108 ++++++ tests/components/karakeep/test_sensor.py | 48 +++ 21 files changed, 1260 insertions(+) create mode 100644 homeassistant/components/karakeep/__init__.py create mode 100644 homeassistant/components/karakeep/config_flow.py create mode 100644 homeassistant/components/karakeep/const.py create mode 100644 homeassistant/components/karakeep/coordinator.py create mode 100644 homeassistant/components/karakeep/entity.py create mode 100644 homeassistant/components/karakeep/icons.json create mode 100644 homeassistant/components/karakeep/manifest.json create mode 100644 homeassistant/components/karakeep/quality_scale.yaml create mode 100644 homeassistant/components/karakeep/sensor.py create mode 100644 homeassistant/components/karakeep/strings.json create mode 100644 tests/components/karakeep/__init__.py create mode 100644 tests/components/karakeep/conftest.py create mode 100644 tests/components/karakeep/const.py create mode 100644 tests/components/karakeep/snapshots/test_sensor.ambr create mode 100644 tests/components/karakeep/test_config_flow.py create mode 100644 tests/components/karakeep/test_init.py create mode 100644 tests/components/karakeep/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 402a02066d06..b5bcbe335b65 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -944,6 +944,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley +/homeassistant/components/karakeep/ @sli-cka +/tests/components/karakeep/ @sli-cka /homeassistant/components/keba/ @dannerph /homeassistant/components/keenetic_ndms2/ @foxel /tests/components/keenetic_ndms2/ @foxel diff --git a/homeassistant/components/karakeep/__init__.py b/homeassistant/components/karakeep/__init__.py new file mode 100644 index 000000000000..6710fe6dfc5b --- /dev/null +++ b/homeassistant/components/karakeep/__init__.py @@ -0,0 +1,33 @@ +"""The Karakeep integration.""" + +from aiokarakeep import KarakeepClient + +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import KarakeepConfigEntry, KarakeepDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: KarakeepConfigEntry) -> bool: + """Set up Karakeep from a config entry.""" + client = KarakeepClient( + entry.data[CONF_URL], + entry.data[CONF_TOKEN], + async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), + ) + coordinator = KarakeepDataUpdateCoordinator(hass, entry, client) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: KarakeepConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/karakeep/config_flow.py b/homeassistant/components/karakeep/config_flow.py new file mode 100644 index 000000000000..b1413a4c0858 --- /dev/null +++ b/homeassistant/components/karakeep/config_flow.py @@ -0,0 +1,104 @@ +"""Config flow for Karakeep.""" + +import logging +from typing import Any, override + +from aiokarakeep import ( + KarakeepApiError, + KarakeepAuthError, + KarakeepClient, + KarakeepConnectionError, + KarakeepInvalidResponseError, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_VERIFY_SSL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +class KarakeepConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Karakeep.""" + + VERSION = 1 + + async def _async_validate_input( + self, url: str, token: str, verify_ssl: bool + ) -> dict[str, str]: + """Validate the user input allows us to connect.""" + errors: dict[str, str] = {} + + session = async_get_clientsession(self.hass, verify_ssl) + client = KarakeepClient(url, token, session) + + try: + await client.async_get_stats() + except KarakeepAuthError: + errors["base"] = "invalid_auth" + except KarakeepConnectionError: + errors["base"] = "cannot_connect" + except KarakeepApiError, KarakeepInvalidResponseError: + errors["base"] = "api_error" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + url = _normalize_url(user_input[CONF_URL]) + token = user_input[CONF_TOKEN].strip() + verify_ssl = user_input[CONF_VERIFY_SSL] + + if url is None: + errors["base"] = "invalid_url_format" + else: + self._async_abort_entries_match({CONF_URL: url}) + + errors = await self._async_validate_input(url, token, verify_ssl) + if not errors: + return self.async_create_entry( + title="Karakeep", + data={ + CONF_URL: url, + CONF_TOKEN: token, + CONF_VERIFY_SSL: verify_ssl, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + +def _normalize_url(raw_url: str) -> str | None: + """Return the normalized base URL, or None if it is not a valid URL.""" + try: + parsed_url = URL(raw_url.strip()) + except ValueError: + return None + if parsed_url.scheme not in ("http", "https") or not parsed_url.host: + return None + return str(parsed_url).rstrip("/") diff --git a/homeassistant/components/karakeep/const.py b/homeassistant/components/karakeep/const.py new file mode 100644 index 000000000000..b2c5fe1c0604 --- /dev/null +++ b/homeassistant/components/karakeep/const.py @@ -0,0 +1,12 @@ +"""Constants for the Karakeep integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "karakeep" + +DEFAULT_VERIFY_SSL = True +UPDATE_INTERVAL = timedelta(seconds=300) + +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/karakeep/coordinator.py b/homeassistant/components/karakeep/coordinator.py new file mode 100644 index 000000000000..01033def739f --- /dev/null +++ b/homeassistant/components/karakeep/coordinator.py @@ -0,0 +1,67 @@ +"""Data update coordinator for the Karakeep integration.""" + +import logging +from typing import override + +from aiokarakeep import ( + KarakeepApiError, + KarakeepAuthError, + KarakeepClient, + KarakeepConnectionError, + KarakeepInvalidResponseError, + KarakeepStats, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type KarakeepConfigEntry = ConfigEntry[KarakeepDataUpdateCoordinator] + + +class KarakeepDataUpdateCoordinator(DataUpdateCoordinator[KarakeepStats]): + """Class to manage fetching Karakeep data.""" + + config_entry: KarakeepConfigEntry + version: str | None = None + + def __init__( + self, + hass: HomeAssistant, + entry: KarakeepConfigEntry, + client: KarakeepClient, + ) -> None: + """Initialize the coordinator.""" + self.client = client + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + + @override + async def _async_setup(self) -> None: + """Fetch the server version once during setup.""" + try: + self.version = await self.client.async_get_version() + except (KarakeepApiError, KarakeepConnectionError) as err: + raise UpdateFailed(f"Error communicating with Karakeep: {err}") from err + + @override + async def _async_update_data(self) -> KarakeepStats: + """Fetch data from Karakeep API.""" + try: + return await self.client.async_get_stats() + except KarakeepAuthError as err: + raise UpdateFailed("Invalid Karakeep API token") from err + except KarakeepConnectionError as err: + raise UpdateFailed(f"Error communicating with Karakeep: {err}") from err + except (KarakeepApiError, KarakeepInvalidResponseError) as err: + raise UpdateFailed(f"Invalid response from Karakeep: {err}") from err diff --git a/homeassistant/components/karakeep/entity.py b/homeassistant/components/karakeep/entity.py new file mode 100644 index 000000000000..347468f460be --- /dev/null +++ b/homeassistant/components/karakeep/entity.py @@ -0,0 +1,26 @@ +"""Base entity for the Karakeep integration.""" + +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import KarakeepDataUpdateCoordinator + + +class KarakeepEntity(CoordinatorEntity[KarakeepDataUpdateCoordinator]): + """Base class for Karakeep entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: KarakeepDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + url = coordinator.config_entry.data[CONF_URL] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Karakeep", + entry_type=DeviceEntryType.SERVICE, + configuration_url=url, + sw_version=coordinator.version, + ) diff --git a/homeassistant/components/karakeep/icons.json b/homeassistant/components/karakeep/icons.json new file mode 100644 index 000000000000..85ffe948b51f --- /dev/null +++ b/homeassistant/components/karakeep/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "archived": { + "default": "mdi:archive" + }, + "bookmarks": { + "default": "mdi:bookmark" + }, + "favorites": { + "default": "mdi:star" + }, + "highlights": { + "default": "mdi:marker" + }, + "lists": { + "default": "mdi:format-list-bulleted" + }, + "tags": { + "default": "mdi:tag" + } + } + } +} diff --git a/homeassistant/components/karakeep/manifest.json b/homeassistant/components/karakeep/manifest.json new file mode 100644 index 000000000000..7561436b01b6 --- /dev/null +++ b/homeassistant/components/karakeep/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "karakeep", + "name": "Karakeep", + "codeowners": ["@sli-cka"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/karakeep", + "integration_type": "service", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["aiokarakeep==0.3.0"] +} diff --git a/homeassistant/components/karakeep/quality_scale.yaml b/homeassistant/components/karakeep/quality_scale.yaml new file mode 100644 index 000000000000..4df22ff58d89 --- /dev/null +++ b/homeassistant/components/karakeep/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not have custom service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not have custom service actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: Entities do not subscribe to external events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not have custom service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration does not use discovery. + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Karakeep exposes one service device per config entry. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: + status: exempt + comment: This integration does not raise translatable Home Assistant exceptions. + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Karakeep exposes one service device per config entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/karakeep/sensor.py b/homeassistant/components/karakeep/sensor.py new file mode 100644 index 000000000000..88013d5f36a3 --- /dev/null +++ b/homeassistant/components/karakeep/sensor.py @@ -0,0 +1,100 @@ +"""Sensor platform for Karakeep.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from aiokarakeep import KarakeepStats + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import KarakeepConfigEntry +from .entity import KarakeepEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KarakeepSensorEntityDescription(SensorEntityDescription): + """Describes a Karakeep sensor.""" + + value_fn: Callable[[KarakeepStats], int] + + +SENSOR_DESCRIPTIONS: tuple[KarakeepSensorEntityDescription, ...] = ( + KarakeepSensorEntityDescription( + key="bookmarks", + translation_key="bookmarks", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_bookmarks, + ), + KarakeepSensorEntityDescription( + key="favorites", + translation_key="favorites", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_favorites, + ), + KarakeepSensorEntityDescription( + key="archived", + translation_key="archived", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_archived, + ), + KarakeepSensorEntityDescription( + key="highlights", + translation_key="highlights", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_highlights, + ), + KarakeepSensorEntityDescription( + key="lists", + translation_key="lists", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_lists, + ), + KarakeepSensorEntityDescription( + key="tags", + translation_key="tags", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_tags, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KarakeepConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Karakeep sensors based on a config entry.""" + async_add_entities( + KarakeepStatSensor(entry, description) for description in SENSOR_DESCRIPTIONS + ) + + +class KarakeepStatSensor(KarakeepEntity, SensorEntity): + """Representation of a Karakeep statistic as a sensor entity.""" + + entity_description: KarakeepSensorEntityDescription + + def __init__( + self, + entry: KarakeepConfigEntry, + entity_description: KarakeepSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(entry.runtime_data) + self.entity_description = entity_description + self._attr_unique_id = f"{entry.entry_id}_{entity_description.key}" + + @property + @override + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/karakeep/strings.json b/homeassistant/components/karakeep/strings.json new file mode 100644 index 000000000000..1aceb6c4e076 --- /dev/null +++ b/homeassistant/components/karakeep/strings.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "api_error": "The Karakeep API returned an unexpected response.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url_format": "Enter a valid URL including http:// or https://.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "token": "API token", + "url": "URL", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "token": "The API token used to connect to your Karakeep instance.", + "url": "The URL of your Karakeep instance, including http:// or https://.", + "verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." + }, + "description": "Connect to your Karakeep instance." + } + } + }, + "entity": { + "sensor": { + "archived": { + "name": "Archived", + "unit_of_measurement": "items" + }, + "bookmarks": { + "name": "Bookmarks", + "unit_of_measurement": "bookmarks" + }, + "favorites": { + "name": "Favorites", + "unit_of_measurement": "favorites" + }, + "highlights": { + "name": "Highlights", + "unit_of_measurement": "highlights" + }, + "lists": { + "name": "Lists", + "unit_of_measurement": "lists" + }, + "tags": { + "name": "Tags", + "unit_of_measurement": "tags" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 22a5977d6e25..098e872eb0a6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -389,6 +389,7 @@ FLOWS = { "justnimbus", "jvc_projector", "kaleidescape", + "karakeep", "keenetic_ndms2", "kegtron", "keymitt_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index dd31413921a1..92c50b56e104 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3500,6 +3500,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "karakeep": { + "name": "Karakeep", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "keba": { "name": "Keba Charging Station", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 5ce1aa247ce2..fc59191372d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -311,6 +311,9 @@ aioimmich==0.16.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 +# homeassistant.components.karakeep +aiokarakeep==0.3.0 + # homeassistant.components.kef aiokef==0.2.16 diff --git a/tests/components/karakeep/__init__.py b/tests/components/karakeep/__init__.py new file mode 100644 index 000000000000..19620431a507 --- /dev/null +++ b/tests/components/karakeep/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Karakeep integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Karakeep integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/karakeep/conftest.py b/tests/components/karakeep/conftest.py new file mode 100644 index 000000000000..8d7f6a4ceb83 --- /dev/null +++ b/tests/components/karakeep/conftest.py @@ -0,0 +1,57 @@ +"""Karakeep tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.karakeep.const import DOMAIN +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL + +from .const import TEST_STATS, TEST_TOKEN, TEST_URL, TEST_VERSION + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.karakeep.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_karakeep_client() -> Generator[AsyncMock]: + """Mock a Karakeep client.""" + with ( + patch( + "homeassistant.components.karakeep.KarakeepClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.karakeep.config_flow.KarakeepClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_stats.return_value = TEST_STATS + client.async_get_version.return_value = TEST_VERSION + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Karakeep", + data={ + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: True, + }, + entry_id="01KVCW3ET6GZ025S7ARE8D5M8W", + ) diff --git a/tests/components/karakeep/const.py b/tests/components/karakeep/const.py new file mode 100644 index 000000000000..78566ff89232 --- /dev/null +++ b/tests/components/karakeep/const.py @@ -0,0 +1,16 @@ +"""Constants for Karakeep tests.""" + +from aiokarakeep import KarakeepStats + +TEST_STATS = KarakeepStats( + num_bookmarks=10, + num_favorites=2, + num_archived=3, + num_highlights=4, + num_lists=5, + num_tags=6, +) + +TEST_VERSION = "0.32.0" +TEST_TOKEN = "test-token" +TEST_URL = "https://karakeep.example.com" diff --git a/tests/components/karakeep/snapshots/test_sensor.ambr b/tests/components/karakeep/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..606ca7287550 --- /dev/null +++ b/tests/components/karakeep/snapshots/test_sensor.ambr @@ -0,0 +1,325 @@ +# serializer version: 1 +# name: test_sensors[sensor.karakeep_archived-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_archived', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Archived', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Archived', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'archived', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_archived', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_sensors[sensor.karakeep_archived-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Archived', + : , + : 'items', + }), + 'context': , + 'entity_id': 'sensor.karakeep_archived', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.karakeep_bookmarks-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_bookmarks', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bookmarks', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bookmarks', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bookmarks', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_bookmarks', + 'unit_of_measurement': 'bookmarks', + }) +# --- +# name: test_sensors[sensor.karakeep_bookmarks-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Bookmarks', + : , + : 'bookmarks', + }), + 'context': , + 'entity_id': 'sensor.karakeep_bookmarks', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.karakeep_favorites-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_favorites', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Favorites', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Favorites', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorites', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_favorites', + 'unit_of_measurement': 'favorites', + }) +# --- +# name: test_sensors[sensor.karakeep_favorites-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Favorites', + : , + : 'favorites', + }), + 'context': , + 'entity_id': 'sensor.karakeep_favorites', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.karakeep_highlights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_highlights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Highlights', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highlights', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'highlights', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_highlights', + 'unit_of_measurement': 'highlights', + }) +# --- +# name: test_sensors[sensor.karakeep_highlights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Highlights', + : , + : 'highlights', + }), + 'context': , + 'entity_id': 'sensor.karakeep_highlights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[sensor.karakeep_lists-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_lists', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lists', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lists', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lists', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_lists', + 'unit_of_measurement': 'lists', + }) +# --- +# name: test_sensors[sensor.karakeep_lists-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Lists', + : , + : 'lists', + }), + 'context': , + 'entity_id': 'sensor.karakeep_lists', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[sensor.karakeep_tags-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_tags', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tags', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tags', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tags', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_tags', + 'unit_of_measurement': 'tags', + }) +# --- +# name: test_sensors[sensor.karakeep_tags-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Tags', + : , + : 'tags', + }), + 'context': , + 'entity_id': 'sensor.karakeep_tags', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- diff --git a/tests/components/karakeep/test_config_flow.py b/tests/components/karakeep/test_config_flow.py new file mode 100644 index 000000000000..89cfb333b28e --- /dev/null +++ b/tests/components/karakeep/test_config_flow.py @@ -0,0 +1,163 @@ +"""Tests for the Karakeep config flow.""" + +from unittest.mock import AsyncMock + +from aiokarakeep import KarakeepApiError, KarakeepAuthError, KarakeepConnectionError +import pytest + +from homeassistant.components.karakeep.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_TOKEN, TEST_URL + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_full_flow(hass: HomeAssistant, mock_karakeep_client: AsyncMock) -> None: + """Test the full user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: f"{TEST_URL}/", + CONF_TOKEN: f" {TEST_TOKEN} ", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Karakeep" + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: False, + } + assert result["result"].unique_id is None + mock_karakeep_client.async_get_stats.assert_awaited_once() + + +@pytest.mark.parametrize("invalid_url", ["karakeep.example.com", "http://["]) +@pytest.mark.usefixtures("mock_setup_entry", "mock_karakeep_client") +async def test_invalid_url(hass: HomeAssistant, invalid_url: str) -> None: + """Test invalid URL errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: invalid_url, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url_format"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: True, + } + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (KarakeepAuthError("Invalid token", 401), "invalid_auth"), + (KarakeepConnectionError("Cannot connect"), "cannot_connect"), + (KarakeepApiError("API error", 500), "api_error"), + (Exception("Boom"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_flow_errors( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + side_effect: Exception, + error: str, +) -> None: + """Test config flow errors.""" + mock_karakeep_client.async_get_stats.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_karakeep_client.async_get_stats.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: True, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_duplicate( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate config flow aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: f"{TEST_URL}/", + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_karakeep_client.async_get_stats.assert_not_awaited() diff --git a/tests/components/karakeep/test_init.py b/tests/components/karakeep/test_init.py new file mode 100644 index 000000000000..c76941b9d69d --- /dev/null +++ b/tests/components/karakeep/test_init.py @@ -0,0 +1,108 @@ +"""Tests for the Karakeep integration setup.""" + +from unittest.mock import AsyncMock, patch + +from aiokarakeep import ( + KarakeepApiError, + KarakeepAuthError, + KarakeepConnectionError, + KarakeepInvalidResponseError, +) +import pytest + +from homeassistant.components.karakeep.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting up the integration.""" + with patch( + "homeassistant.components.karakeep.async_get_clientsession" + ) as mock_get_clientsession: + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_get_clientsession.assert_called_once_with(hass, True) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + mock_karakeep_client.async_get_stats.assert_awaited_once() + + +@pytest.mark.parametrize( + "side_effect", + [ + KarakeepAuthError("Invalid token", 401), + KarakeepConnectionError("Cannot connect"), + KarakeepApiError("API error", 500), + KarakeepInvalidResponseError("Invalid response"), + ], +) +async def test_setup_entry_update_failure( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test setup retries on update failures.""" + mock_karakeep_client.async_get_stats.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_version_failure( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries when the version fetch fails.""" + mock_karakeep_client.async_get_version.side_effect = KarakeepConnectionError( + "Cannot connect" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_version_unavailable( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup succeeds without a version when the endpoint is unavailable.""" + mock_karakeep_client.async_get_version.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry.sw_version is None + + +async def test_unload_entry( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/karakeep/test_sensor.py b/tests/components/karakeep/test_sensor.py new file mode 100644 index 000000000000..44daeb05ac09 --- /dev/null +++ b/tests/components/karakeep/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for the Karakeep sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration +from .const import TEST_VERSION + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Karakeep sensors.""" + with patch("homeassistant.components.karakeep.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_device_info( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry entry.""" + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={("karakeep", mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry.name == "Karakeep" + assert device_entry.manufacturer == "Karakeep" + assert device_entry.sw_version == TEST_VERSION From 72acc8f48ff5376d75d49287fad2232836c9d313 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Jul 2026 17:41:58 +0200 Subject: [PATCH 558/707] Add sensor platform to Mikrotik (#175214) --- homeassistant/components/mikrotik/__init__.py | 7 +- homeassistant/components/mikrotik/const.py | 31 +- .../components/mikrotik/coordinator.py | 10 + homeassistant/components/mikrotik/entity.py | 42 +++ homeassistant/components/mikrotik/icons.json | 15 + homeassistant/components/mikrotik/sensor.py | 189 ++++++++++ .../components/mikrotik/strings.json | 13 + tests/components/mikrotik/__init__.py | 297 ++++++---------- tests/components/mikrotik/conftest.py | 15 +- tests/components/mikrotik/const.py | 184 ++++++++++ .../mikrotik/snapshots/test_sensor.ambr | 333 ++++++++++++++++++ tests/components/mikrotik/test_config_flow.py | 37 +- .../mikrotik/test_device_tracker.py | 22 +- tests/components/mikrotik/test_init.py | 44 +-- tests/components/mikrotik/test_sensor.py | 115 ++++++ 15 files changed, 1081 insertions(+), 273 deletions(-) create mode 100644 homeassistant/components/mikrotik/entity.py create mode 100644 homeassistant/components/mikrotik/icons.json create mode 100644 homeassistant/components/mikrotik/sensor.py create mode 100644 tests/components/mikrotik/const.py create mode 100644 tests/components/mikrotik/snapshots/test_sensor.ambr create mode 100644 tests/components/mikrotik/test_sensor.py diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index f4025bf10079..43e32e55d571 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -16,7 +16,10 @@ from .coordinator import ( mikrotik_config_entry_errors, ) -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [ + Platform.DEVICE_TRACKER, + Platform.SENSOR, +] def _call_api(data: dict[str, Any]) -> Api: @@ -43,7 +46,7 @@ async def async_setup_entry( device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(DOMAIN, coordinator.serial_num)}, + identifiers={(DOMAIN, coordinator.serial_num)}, manufacturer=ATTR_MANUFACTURER, model=coordinator.model, name=coordinator.hostname, diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 06d14250756e..c710f66a14e3 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -15,36 +15,39 @@ CONF_ARP_PING: Final = "arp_ping" CONF_FORCE_DHCP: Final = "force_dhcp" CONF_DETECTION_TIME: Final = "detection_time" - NAME: Final = "name" -INFO: Final = "info" -IDENTITY: Final = "identity" -ARP: Final = "arp" +ARP: Final = "arp" CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" -WIRELESS: Final = "wireless" -WIFIWAVE2: Final = "wifiwave2" -WIFI: Final = "wifi" -IS_WIRELESS: Final = "is_wireless" +HEALTH: Final = "health" +IDENTITY: Final = "identity" +INFO: Final = "info" IS_CAPSMAN: Final = "is_capsman" -IS_WIFIWAVE2: Final = "is_wifiwave2" IS_WIFI: Final = "is_wifi" +IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIRELESS: Final = "is_wireless" +SYSTEM: Final = "system" +WIFI: Final = "wifi" +WIFIWAVE2: Final = "wifiwave2" +WIRELESS: Final = "wireless" MIKROTIK_SERVICES: Final = { ARP: "/ip/arp/getall", CAPSMAN: "/caps-man/registration-table/getall", DHCP: "/ip/dhcp-server/lease/getall", + HEALTH: "/system/health/print", IDENTITY: "/system/identity/getall", INFO: "/system/routerboard/getall", - WIRELESS: "/interface/wireless/registration-table/getall", - WIFIWAVE2: "/interface/wifiwave2/registration-table/print", - WIFI: "/interface/wifi/registration-table/print", - IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", - IS_WIFIWAVE2: "/interface/wifiwave2/print", IS_WIFI: "/interface/wifi/print", + IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIRELESS: "/interface/wireless/print", + SYSTEM: "/system/resource/print", + WIFI: "/interface/wifi/registration-table/print", + WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIRELESS: "/interface/wireless/registration-table/getall", } diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 902681670388..96e935622c4d 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -30,6 +30,7 @@ from .const import ( DEFAULT_DETECTION_TIME, DHCP, DOMAIN, + HEALTH, IDENTITY, INFO, IS_CAPSMAN, @@ -38,6 +39,7 @@ from .const import ( IS_WIRELESS, MIKROTIK_SERVICES, NAME, + SYSTEM, WIFI, WIFIWAVE2, WIRELESS, @@ -72,6 +74,7 @@ class MikrotikData: self.model: str = "" self.firmware: str = "" self.serial_number: str = "" + self.sensors: dict[str, Any] = {} @staticmethod def load_mac(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: @@ -164,6 +167,13 @@ class MikrotikData: # get new hub firmware version if updated self.firmware = self.get_info(ATTR_FIRMWARE) + self.sensors[HEALTH] = ( + self.command(MIKROTIK_SERVICES[HEALTH], suppress_errors=True) or [] + ) + self.sensors[SYSTEM] = ( + self.command(MIKROTIK_SERVICES[SYSTEM], suppress_errors=True) or [] + ) + if not device_list: return diff --git a/homeassistant/components/mikrotik/entity.py b/homeassistant/components/mikrotik/entity.py new file mode 100644 index 000000000000..3a0b8c79f669 --- /dev/null +++ b/homeassistant/components/mikrotik/entity.py @@ -0,0 +1,42 @@ +"""Base class for Mikrotik routers entities.""" + +from yarl import URL + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MikrotikDataUpdateCoordinator + + +class MikrotikEntity[DescriptionT: EntityDescription]( + CoordinatorEntity[MikrotikDataUpdateCoordinator] +): + """Base class for Mikrotik entities.""" + + _attr_has_entity_name = True + entity_description: DescriptionT + + def __init__( + self, + coordinator: MikrotikDataUpdateCoordinator, + description: DescriptionT, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + + self._serial = coordinator.api.serial_number + self._attr_device_info = DeviceInfo( + configuration_url=URL.build( + scheme="http", + host=coordinator.host, + ), + identifiers={(DOMAIN, self._serial)}, + name=coordinator.hostname, + manufacturer="Mikrotik", + model=coordinator.model, + sw_version=coordinator.firmware, + serial_number=self._serial, + ) diff --git a/homeassistant/components/mikrotik/icons.json b/homeassistant/components/mikrotik/icons.json new file mode 100644 index 000000000000..adccb6039904 --- /dev/null +++ b/homeassistant/components/mikrotik/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "cpu-load": { + "default": "mdi:chip" + }, + "disk-usage": { + "default": "mdi:harddisk" + }, + "memory-usage": { + "default": "mdi:memory" + } + } + } +} diff --git a/homeassistant/components/mikrotik/sensor.py b/homeassistant/components/mikrotik/sensor.py new file mode 100644 index 000000000000..ecae2a82d3e5 --- /dev/null +++ b/homeassistant/components/mikrotik/sensor.py @@ -0,0 +1,189 @@ +"""Support for Mikrotik routers sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Final, override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricPotential, + UnitOfRatio, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow + +from .const import HEALTH, SYSTEM +from .coordinator import _LOGGER, MikrotikConfigEntry, MikrotikDataUpdateCoordinator +from .entity import MikrotikEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class MikrotikSensorEntityDescription(SensorEntityDescription): + """Shared Mikrotik Sensors entity description.""" + + value: Callable[[dict[str, Any]], StateType | datetime] + type: str + index: int + + +def _calculate_uptime(data: dict[str, Any]) -> datetime | None: + """Calculate uptime.""" + # e.g. 1d3h39m30s + uptime_string = data["uptime"] + + total = 0 + num = 0 + + for ch in uptime_string.strip(): + if ch.isdigit(): + num = num * 10 + int(ch) + else: + if ch == "w": + total += num * (60 * 60 * 24 * 7) + elif ch == "d": + total += num * (60 * 60 * 24) + elif ch == "h": + total += num * (60 * 60) + elif ch == "m": + total += num * 60 + elif ch == "s": + total += num + else: + _LOGGER.warning("Unknown uptime format: %s", uptime_string) + return None + + num = 0 + + if num != 0: + _LOGGER.warning("Unknown uptime format: %s", uptime_string) + return None + + return utcnow() - timedelta(seconds=total) + + +SENSORS: Final = ( + MikrotikSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value=lambda _data: _data["value"], + type=HEALTH, + index=1, + ), + MikrotikSensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda _data: _data["value"], + type=HEALTH, + index=0, + ), + MikrotikSensorEntityDescription( + key="cpu-load", + translation_key="cpu_load", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + suggested_display_precision=2, + value=lambda _data: _data["cpu-load"], + type=SYSTEM, + index=0, + ), + MikrotikSensorEntityDescription( + key="memory-usage", + translation_key="memory_usage", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + suggested_display_precision=2, + value=lambda _data: ( + None + if (total := _data.get("total-memory", 0)) == 0 + else (total - _data.get("free-memory", 0)) / total * 100 + ), + type=SYSTEM, + index=0, + ), + MikrotikSensorEntityDescription( + key="disk-usage", + translation_key="disk_usage", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + suggested_display_precision=2, + value=lambda _data: ( + None + if (total := _data.get("total-hdd-space", 0)) == 0 + else (total - _data.get("free-hdd-space", 0)) / total * 100 + ), + type=SYSTEM, + index=0, + ), + MikrotikSensorEntityDescription( + key="uptime", + device_class=SensorDeviceClass.UPTIME, + value=_calculate_uptime, + type=SYSTEM, + index=0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MikrotikConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Mikrotik sensors based on a config entry.""" + + coordinator = entry.runtime_data + + sensors_list = [ + MikrotikSensorEntity(coordinator, sensor_desc) + for sensor_desc in SENSORS + if len(coordinator.api.sensors.get(sensor_desc.type, [])) + >= (sensor_desc.index + 1) + ] + + async_add_entities(sensors_list) + + +class MikrotikSensorEntity( + MikrotikEntity[MikrotikSensorEntityDescription], SensorEntity +): + """Sensor device.""" + + entity_description: MikrotikSensorEntityDescription + + def __init__( + self, + coordinator: MikrotikDataUpdateCoordinator, + description: MikrotikSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description) + + self._attr_unique_id = f"{self._serial}_{description.key}" + + @property + @override + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + data_list = self.coordinator.api.sensors[self.entity_description.type] + data_entry = data_list[self.entity_description.index] + + return self.entity_description.value(data_entry) diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 99a94d485f08..0f63b1e3ab70 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -30,6 +30,19 @@ } } }, + "entity": { + "sensor": { + "cpu_load": { + "name": "CPU usage" + }, + "disk_usage": { + "name": "Disk usage" + }, + "memory_usage": { + "name": "Memory usage" + } + } + }, "exceptions": { "cannot_connect": { "message": "Error connecting: {error}" diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index b700f021b0da..129137420693 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -1,183 +1,73 @@ -"""Tests for the Mikrotik component.""" +"""Tests for the Mikrotik integration.""" from typing import Any from unittest.mock import patch from homeassistant.components import mikrotik -from homeassistant.components.mikrotik.const import ( - CONF_ARP_PING, - CONF_DETECTION_TIME, - CONF_FORCE_DHCP, - DEFAULT_DETECTION_TIME, -) -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.components.mikrotik.const import DOMAIN from homeassistant.core import HomeAssistant +from .const import ( + ARP_DATA, + DHCP_DATA, + HEALTH_DATA, + MOCK_DATA, + SYSTEM_DATA, + TEST_FIRMWARE, + TEST_MODEL, + TEST_SERIAL_NUMBER, + WIFIWAVE2_DATA, + WIRELESS_DATA, +) + from tests.common import MockConfigEntry -MOCK_DATA = { - CONF_NAME: "Mikrotik", - CONF_HOST: "0.0.0.0", - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - CONF_PORT: 8278, - CONF_VERIFY_SSL: False, -} -MOCK_OPTIONS = { - CONF_ARP_PING: False, - CONF_FORCE_DHCP: False, - CONF_DETECTION_TIME: DEFAULT_DETECTION_TIME, -} - -DEVICE_1_DHCP = { - ".id": "*1A", - "address": "0.0.0.1", - "mac-address": "00:00:00:00:00:01", - "active-address": "0.0.0.1", - "host-name": "Device_1", - "comment": "Mobile", -} -DEVICE_2_DHCP = { - ".id": "*1B", - "address": "0.0.0.2", - "mac-address": "00:00:00:00:00:02", - "active-address": "0.0.0.2", - "host-name": "Device_2", - "comment": "PC", -} -DEVICE_3_DHCP_NUMERIC_NAME = { - ".id": "*1C", - "address": "0.0.0.3", - "mac-address": "00:00:00:00:00:03", - "active-address": "0.0.0.3", - "host-name": 123, - "comment": "Mobile", -} -DEVICE_4_DHCP = { - ".id": "*F7", - "address": "0.0.0.4", - "mac-address": "00:00:00:00:00:04", - "active-address": "0.0.0.4", - "host-name": "Device_4", - "comment": "Wifiwave2 device", -} -DEVICE_1_WIRELESS = { - ".id": "*264", - "interface": "wlan1", - "mac-address": "00:00:00:00:00:01", - "ap": False, - "wds": False, - "bridge": False, - "rx-rate": "72.2Mbps-20MHz/1S/SGI", - "tx-rate": "72.2Mbps-20MHz/1S/SGI", - "packets": "59542,17464", - "bytes": "17536671,2966351", - "frames": "59542,17472", - "frame-bytes": "17655785,2862445", - "hw-frames": "78935,38395", - "hw-frame-bytes": "25636019,4063445", - "tx-frames-timed-out": 0, - "uptime": "5h49m36s", - "last-activity": "170ms", - "signal-strength": "-62@1Mbps", - "signal-to-noise": 52, - "signal-strength-ch0": -63, - "signal-strength-ch1": -69, - "strength-at-rates": ( - "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms," - "-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms," - "-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms," - "-65@HT20-7 5s680ms" - ), - "tx-ccq": 93, - "p-throughput": 54928, - "last-ip": "0.0.0.1", - "802.1x-port-enabled": True, - "authentication-type": "wpa2-psk", - "encryption": "aes-ccm", - "group-encryption": "aes-ccm", - "management-protection": False, - "wmm-enabled": True, - "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", -} - -DEVICE_2_WIRELESS = { - **DEVICE_1_WIRELESS, - ".id": "*265", - "mac-address": "00:00:00:00:00:02", - "last-ip": "0.0.0.2", -} -DEVICE_3_WIRELESS = { - **DEVICE_1_WIRELESS, - ".id": "*266", - "mac-address": "00:00:00:00:00:03", - "last-ip": "0.0.0.3", -} - -DEVICE_4_WIFIWAVE2 = { - ".id": "*F7", - "interface": "wifi1", - "ssid": "test-ssid", - "mac-address": "00:00:00:00:00:04", - "uptime": "2d15h28m27s", - "signal": -47, - "tx-rate": 54000000, - "rx-rate": 54000000, - "packets": "17748,18516", - "bytes": "1851474,2037295", - "tx-bits-per-second": 0, - "rx-bits-per-second": 0, - "authorized": True, -} - -DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] - -WIRELESS_DATA = [DEVICE_1_WIRELESS] -WIFIWAVE2_DATA = [DEVICE_4_WIFIWAVE2] - -ARP_DATA = [ - { - ".id": "*1", - "address": "0.0.0.1", - "mac-address": "00:00:00:00:00:01", - "interface": "bridge", - "published": False, - "invalid": False, - "DHCP": True, - "dynamic": True, - "complete": True, - "disabled": False, - }, - { - ".id": "*2", - "address": "0.0.0.2", - "mac-address": "00:00:00:00:00:02", - "interface": "bridge", - "published": False, - "invalid": False, - "DHCP": True, - "dynamic": True, - "complete": True, - "disabled": False, - }, -] +def _build_command_responses( + *, + support_wireless: bool, + support_wifiwave2: bool, + dhcp_data: list[dict[str, Any]], + wireless_data: list[dict[str, Any]], + wifiwave2_data: list[dict[str, Any]], + health_data: list[dict[str, Any]], + system_data: list[dict[str, Any]], +) -> dict[str, Any]: + """Build mocked service responses for the Mikrotik coordinator.""" + return { + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IDENTITY]: [ + {"name": "Mikrotik"} + ], + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.INFO]: [ + { + "model": TEST_MODEL, + "current-firmware": TEST_FIRMWARE, + "serial-number": TEST_SERIAL_NUMBER, + } + ], + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_CAPSMAN]: [], + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: support_wireless, + mikrotik.const.MIKROTIK_SERVICES[ + mikrotik.const.IS_WIFIWAVE2 + ]: support_wifiwave2, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFI]: False, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: dhcp_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: wireless_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIFIWAVE2]: wifiwave2_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: ARP_DATA, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.HEALTH]: health_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.SYSTEM]: system_data, + } -async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: - """Set up Mikrotik integration successfully.""" - support_wireless: bool = kwargs.get("support_wireless", True) - support_wifiwave2: bool = kwargs.get("support_wifiwave2", False) - dhcp_data: list[dict[str, Any]] = kwargs.get("dhcp_data", DHCP_DATA) - wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) - wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + *, + command_responses: dict[str, Any], +) -> None: + """Set up the component with mocked Mikrotik command responses.""" + config_entry.add_to_hass(hass) def mock_command( self, @@ -185,35 +75,54 @@ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: params: dict[str, Any] | None = None, suppress_errors: bool = False, ) -> Any: - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: - return support_wireless - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: - return support_wifiwave2 - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: - return dhcp_data - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: - return wireless_data - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIFIWAVE2]: - return wifiwave2_data - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: - return ARP_DATA - return {} - - options: dict[str, Any] = {} - if "force_dhcp" in kwargs: - options.update({"force_dhcp": True}) - - if "arp_ping" in kwargs: - options.update({"arp_ping": True}) - - config_entry = MockConfigEntry( - domain=mikrotik.DOMAIN, data=MOCK_DATA, options=options - ) - config_entry.add_to_hass(hass) + return command_responses.get(cmd, {}) with ( patch("librouteros.connect"), patch.object(mikrotik.coordinator.MikrotikData, "command", new=mock_command), ): - await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def create_mock_config_entry( + *, + data: dict[str, Any] | None = None, + options: dict[str, Any] | None = None, + domain: str = DOMAIN, +) -> MockConfigEntry: + """Create a Mikrotik test config entry with optional overrides.""" + return MockConfigEntry( + domain=domain, + data=data or MOCK_DATA, + options=options or {}, + version=1, + minor_version=1, + ) + + +async def setup_mikrotik_entry( + hass: HomeAssistant, + **kwargs: Any, +) -> MockConfigEntry: + """Set up a Mikrotik config entry with defaults that tests can override.""" + options = dict(kwargs.get("options", {})) + if "force_dhcp" in kwargs: + options["force_dhcp"] = True + if "arp_ping" in kwargs: + options["arp_ping"] = True + + config_entry = create_mock_config_entry(options=options) + + command_responses = _build_command_responses( + support_wireless=kwargs.get("support_wireless", True), + support_wifiwave2=kwargs.get("support_wifiwave2", False), + dhcp_data=kwargs.get("dhcp_data", DHCP_DATA), + wireless_data=kwargs.get("wireless_data", WIRELESS_DATA), + wifiwave2_data=kwargs.get("wifiwave2_data", WIFIWAVE2_DATA), + health_data=kwargs.get("health_data", HEALTH_DATA), + system_data=kwargs.get("system_data", SYSTEM_DATA), + ) + + await setup_integration(hass, config_entry, command_responses=command_responses) + return config_entry diff --git a/tests/components/mikrotik/conftest.py b/tests/components/mikrotik/conftest.py index 2152786deb6f..e9dfe8fb3d7c 100644 --- a/tests/components/mikrotik/conftest.py +++ b/tests/components/mikrotik/conftest.py @@ -1,12 +1,21 @@ -"""Config tests Mikrotik.""" +"""Mikrotik test configuration.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch import pytest +from . import create_mock_config_entry + + +@pytest.fixture +def mock_config_entry(): + """Create Mikrotik config entries with optional overrides.""" + return create_mock_config_entry + @pytest.fixture(autouse=True) -def mock_api(): +def mock_api() -> Generator[MagicMock]: """Mock api.""" with ( patch("librouteros.create_transport"), diff --git a/tests/components/mikrotik/const.py b/tests/components/mikrotik/const.py new file mode 100644 index 000000000000..761a9b986318 --- /dev/null +++ b/tests/components/mikrotik/const.py @@ -0,0 +1,184 @@ +"""Constants for Mikrotik tests.""" + +from homeassistant.components.mikrotik.const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_DETECTION_TIME, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +TEST_MODEL = "RB5009" +TEST_FIRMWARE = "7.18.2" +TEST_SERIAL_NUMBER = "ABC123" + +MOCK_DATA = { + CONF_NAME: "Mikrotik", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, +} + +MOCK_OPTIONS = { + CONF_ARP_PING: False, + CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: DEFAULT_DETECTION_TIME, +} + +DEVICE_1_DHCP = { + ".id": "*1A", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "active-address": "0.0.0.1", + "host-name": "Device_1", + "comment": "Mobile", +} +DEVICE_2_DHCP = { + ".id": "*1B", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "active-address": "0.0.0.2", + "host-name": "Device_2", + "comment": "PC", +} +DEVICE_3_DHCP_NUMERIC_NAME = { + ".id": "*1C", + "address": "0.0.0.3", + "mac-address": "00:00:00:00:00:03", + "active-address": "0.0.0.3", + "host-name": 123, + "comment": "Mobile", +} +DEVICE_4_DHCP = { + ".id": "*F7", + "address": "0.0.0.4", + "mac-address": "00:00:00:00:00:04", + "active-address": "0.0.0.4", + "host-name": "Device_4", + "comment": "Wifiwave2 device", +} +DEVICE_1_WIRELESS = { + ".id": "*264", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:01", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": ( + "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms," + "-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms," + "-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms," + "-65@HT20-7 5s680ms" + ), + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.1", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} + +DEVICE_2_WIRELESS = { + **DEVICE_1_WIRELESS, + ".id": "*265", + "mac-address": "00:00:00:00:00:02", + "last-ip": "0.0.0.2", +} +DEVICE_3_WIRELESS = { + **DEVICE_1_WIRELESS, + ".id": "*266", + "mac-address": "00:00:00:00:00:03", + "last-ip": "0.0.0.3", +} + +DEVICE_4_WIFIWAVE2 = { + ".id": "*F7", + "interface": "wifi1", + "ssid": "test-ssid", + "mac-address": "00:00:00:00:00:04", + "uptime": "2d15h28m27s", + "signal": -47, + "tx-rate": 54000000, + "rx-rate": 54000000, + "packets": "17748,18516", + "bytes": "1851474,2037295", + "tx-bits-per-second": 0, + "rx-bits-per-second": 0, + "authorized": True, +} + +DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] + +WIRELESS_DATA = [DEVICE_1_WIRELESS] +WIFIWAVE2_DATA = [DEVICE_4_WIFIWAVE2] + +ARP_DATA = [ + { + ".id": "*1", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, + { + ".id": "*2", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, +] + +HEALTH_DATA = [ + {"name": "voltage", "value": 24.2}, + {"name": "temperature", "value": 50.0}, +] + +SYSTEM_DATA = [ + { + "cpu-load": 15, + "total-memory": 1000, + "free-memory": 200, + "total-hdd-space": 100, + "free-hdd-space": 25, + "uptime": "1w2d3h4m5s", + } +] diff --git a/tests/components/mikrotik/snapshots/test_sensor.ambr b/tests/components/mikrotik/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..97dc60967ef8 --- /dev/null +++ b/tests/components/mikrotik/snapshots/test_sensor.ambr @@ -0,0 +1,333 @@ +# serializer version: 1 +# name: test_sensor_entities_created[sensor.mikrotik_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'CPU usage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU usage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cpu_load', + 'unique_id': 'ABC123_cpu-load', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mikrotik CPU usage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_disk_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_disk_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Disk usage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk usage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disk_usage', + 'unique_id': 'ABC123_disk-usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_disk_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mikrotik Disk usage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_disk_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.0', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Memory usage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Memory usage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'memory_usage', + 'unique_id': 'ABC123_memory-usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mikrotik Memory usage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Mikrotik Temperature', + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mikrotik_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Mikrotik Uptime', + }), + 'context': , + 'entity_id': 'sensor.mikrotik_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-23T08:55:55+00:00', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Mikrotik Voltage', + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.2', + }) +# --- diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index f65c7f0dfc5b..923d1301d7d2 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -23,8 +23,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - DEMO_USER_INPUT = { CONF_HOST: "0.0.0.0", CONF_USERNAME: "username", @@ -92,9 +90,9 @@ async def test_flow_works(hass: HomeAssistant, api) -> None: assert result["data"][CONF_PORT] == 8278 -async def test_options(hass: HomeAssistant, api) -> None: +async def test_options(hass: HomeAssistant, api, mock_config_entry) -> None: """Test updating options.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = mock_config_entry(data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -122,10 +120,12 @@ async def test_options(hass: HomeAssistant, api) -> None: } -async def test_host_already_configured(hass: HomeAssistant, auth_error) -> None: +async def test_host_already_configured( + hass: HomeAssistant, auth_error, mock_config_entry +) -> None: """Test host already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = mock_config_entry(data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -168,12 +168,9 @@ async def test_wrong_credentials(hass: HomeAssistant, auth_error) -> None: } -async def test_reauth_success(hass: HomeAssistant, api) -> None: +async def test_reauth_success(hass: HomeAssistant, api, mock_config_entry) -> None: """Test we can reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_USER_INPUT, - ) + entry = mock_config_entry(data=DEMO_USER_INPUT) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -196,12 +193,11 @@ async def test_reauth_success(hass: HomeAssistant, api) -> None: assert result2["reason"] == "reauth_successful" -async def test_reauth_failed(hass: HomeAssistant, auth_error) -> None: +async def test_reauth_failed( + hass: HomeAssistant, auth_error, mock_config_entry +) -> None: """Test reauth fails due to wrong password.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_USER_INPUT, - ) + entry = mock_config_entry(data=DEMO_USER_INPUT) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -222,12 +218,11 @@ async def test_reauth_failed(hass: HomeAssistant, auth_error) -> None: } -async def test_reauth_failed_conn_error(hass: HomeAssistant, conn_error) -> None: +async def test_reauth_failed_conn_error( + hass: HomeAssistant, conn_error, mock_config_entry +) -> None: """Test reauth failed due to connection error.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_USER_INPUT, - ) + entry = mock_config_entry(data=DEMO_USER_INPUT) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index d2645916b390..8ea31c6105c3 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import ( +from . import setup_mikrotik_entry +from .const import ( DEVICE_2_WIRELESS, DEVICE_3_DHCP_NUMERIC_NAME, DEVICE_3_WIRELESS, @@ -22,18 +23,19 @@ from . import ( MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA, - setup_mikrotik_entry, ) -from tests.common import MockConfigEntry, async_fire_time_changed, patch +from tests.common import async_fire_time_changed, patch @pytest.fixture def mock_device_registry_devices( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry, ) -> None: """Create device registry devices so the device tracker entities are enabled.""" - config_entry = MockConfigEntry(domain="something_else") + config_entry = mock_config_entry(domain="something_else", data={}) config_entry.add_to_hass(hass) for idx, device in enumerate( @@ -214,11 +216,15 @@ async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) async def test_restoring_devices( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry, ) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" - config_entry = MockConfigEntry( - domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS + config_entry = mock_config_entry( + domain=mikrotik.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 722a243794a8..52d2f1b07d5f 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -4,33 +4,24 @@ from unittest.mock import MagicMock from librouteros.exceptions import ConnectionClosed, LibRouterosError -from homeassistant.components import mikrotik from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import MOCK_DATA - -from tests.common import MockConfigEntry +from . import setup_integration -async def test_successful_config_entry(hass: HomeAssistant) -> None: +async def test_successful_config_entry(hass: HomeAssistant, mock_config_entry) -> None: """Test config entry successful setup.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) + entry = mock_config_entry() + await setup_integration(hass, entry, command_responses={}) assert entry.state is ConfigEntryState.LOADED -async def test_hub_connection_error(hass: HomeAssistant, mock_api: MagicMock) -> None: +async def test_hub_connection_error( + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry +) -> None: """Test setup fails due to connection error.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) + entry = mock_config_entry() entry.add_to_hass(hass) mock_api.side_effect = ConnectionClosed @@ -41,13 +32,10 @@ async def test_hub_connection_error(hass: HomeAssistant, mock_api: MagicMock) -> async def test_hub_authentication_error( - hass: HomeAssistant, mock_api: MagicMock + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry ) -> None: """Test setup fails due to authentication error.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) + entry = mock_config_entry() entry.add_to_hass(hass) mock_api.side_effect = LibRouterosError("invalid user name or password") @@ -57,16 +45,10 @@ async def test_hub_authentication_error( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry(hass: HomeAssistant, mock_config_entry) -> None: """Test unloading an entry.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = mock_config_entry() + await setup_integration(hass, entry, command_responses={}) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/mikrotik/test_sensor.py b/tests/components/mikrotik/test_sensor.py new file mode 100644 index 000000000000..4f0f2c17537f --- /dev/null +++ b/tests/components/mikrotik/test_sensor.py @@ -0,0 +1,115 @@ +"""Tests for the Mikrotik sensor platform.""" + +from unittest.mock import patch + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_mikrotik_entry + +from tests.common import snapshot_platform + + +@freeze_time("2026-01-01T12:00:00+00:00") +async def test_sensor_entities_created( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test Mikrotik sensor entities are created with expected values.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.SENSOR]): + config_entry = await setup_mikrotik_entry(hass) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_sensor_wrong_data(hass: HomeAssistant) -> None: + """Test Mikrotik sensor entities handle missing data gracefully.""" + await setup_mikrotik_entry( + hass, + health_data=[ + {"name": "voltage", "value": 24.2}, + ], + system_data=[ + { + "cpu-load": 15, + "total-memory": 0, + "free-memory": 200, + "total-hdd-space": 0, + "free-hdd-space": 25, + "uptime": None, + } + ], + ) + + assert (state := hass.states.get("sensor.mikrotik_voltage")) + assert state.state == "24.2" + + assert (state := hass.states.get("sensor.mikrotik_temperature")) is None + + assert (state := hass.states.get("sensor.mikrotik_cpu_usage")) + assert state.state == "15" + + assert (state := hass.states.get("sensor.mikrotik_memory_usage")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.mikrotik_disk_usage")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.mikrotik_uptime")) is None + + +@pytest.mark.parametrize( + "uptime_api", + [ + pytest.param("3u2h3m4s", id="invalid_unit"), + pytest.param("2h30", id="missing_unit"), + ], +) +@freeze_time("2026-01-01T12:00:00+00:00") +async def test_sensor_bad_uptime_data( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + uptime_api: str, +) -> None: + """Test Mikrotik sensor entities handle missing data gracefully.""" + + await setup_mikrotik_entry( + hass, + system_data=[ + { + "cpu-load": 15, + "total-memory": 0, + "free-memory": 200, + "total-hdd-space": 0, + "free-hdd-space": 25, + "uptime": uptime_api, + } + ], + ) + + assert f"Unknown uptime format: {uptime_api}" in caplog.text + + assert (state := hass.states.get("sensor.mikrotik_uptime")) + assert state.state == STATE_UNKNOWN + + +async def test_sensor_no_data(hass: HomeAssistant) -> None: + """Test Mikrotik sensor entities handle missing data gracefully.""" + await setup_mikrotik_entry( + hass, + health_data=[], + system_data=[], + ) + + assert hass.states.get("sensor.mikrotik_voltage") is None + assert hass.states.get("sensor.mikrotik_temperature") is None + assert hass.states.get("sensor.mikrotik_cpu_usage") is None + assert hass.states.get("sensor.mikrotik_memory_usage") is None + assert hass.states.get("sensor.mikrotik_disk_usage") is None + assert hass.states.get("sensor.mikrotik_uptime") is None From 20bad4c45b29bc2d979b387df84184f079e5dddd Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Mon, 13 Jul 2026 17:42:34 +0200 Subject: [PATCH 559/707] Sync Duco node names from Duco to Home Assistant (#175312) --- homeassistant/components/duco/__init__.py | 5 +- homeassistant/components/duco/coordinator.py | 40 ++++++- homeassistant/components/duco/entity.py | 5 +- tests/components/duco/conftest.py | 17 +++ tests/components/duco/test_init.py | 116 ++++++++++++++++++- 5 files changed, 174 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py index bc75a4597117..09c79893ad5f 100644 --- a/homeassistant/components/duco/__init__.py +++ b/homeassistant/components/duco/__init__.py @@ -17,8 +17,8 @@ _REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$") async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: """Set up Duco from a config entry.""" - # Remove entity registry entries for the temperature and box_temperature - # sensors that were removed when migrating to python-duco-connectivity. + # Clean up stale temperature registry entries so removed entities from the + # python-duco-connectivity migration do not linger after upgrade. entity_registry = er.async_get(hass) for entity_entry in er.async_entries_for_config_entry( entity_registry, entry.entry_id @@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool ) coordinator = DucoCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index 4e8ef4993a0f..10cbf619c96e 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -1,7 +1,7 @@ """Data update coordinator for the Duco integration.""" from contextlib import suppress -from dataclasses import dataclass +from dataclasses import dataclass, replace import logging from typing import cast, override @@ -11,7 +11,7 @@ from duco_connectivity.exceptions import ( DucoError, DucoResponseError, ) -from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList +from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList, NodeName from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -42,6 +42,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): config_entry: DucoConfigEntry board_info: BoardInfo _supports_time_filter_remain: bool + _configured_node_names: dict[int, str] def __init__( self, @@ -58,8 +59,25 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): update_interval=SCAN_INTERVAL, ) self.client = client + self._configured_node_names = {} self._supports_time_filter_remain = True + async def _async_load_node_names(self) -> None: + """Load configured Duco node names during setup.""" + try: + configured_node_names = await self.client.async_get_node_configs( + parameter="Name" + ) + except DucoError as err: + _LOGGER.debug("Could not fetch Duco node names", exc_info=err) + return + + self._configured_node_names = { + node.node_id: node.name.value + for node in configured_node_names.nodes + if node.name is not None + } + @override async def _async_setup(self) -> None: """Fetch board info once during initial setup.""" @@ -86,6 +104,8 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): translation_key="api_error", ) from err + await self._async_load_node_names() + @override async def _async_update_data(self) -> DucoData: """Fetch node data from the Duco box.""" @@ -102,6 +122,22 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): translation_key="api_error", ) from err + if self._configured_node_names: + nodes = [ + replace( + node, + general=replace( + node.general, + name=NodeName( + self._configured_node_names.get( + node.node_id, node.general.name + ) + ), + ), + ) + for node in nodes + ] + try: node_actions = await self.client.async_get_node_actions() except DucoError as err: diff --git a/homeassistant/components/duco/entity.py b/homeassistant/components/duco/entity.py index 851c99d2d0d4..b14332791f88 100644 --- a/homeassistant/components/duco/entity.py +++ b/homeassistant/components/duco/entity.py @@ -1,6 +1,6 @@ """Base entity for the Duco integration.""" -from typing import override +from typing import TYPE_CHECKING, override from duco_connectivity.models import Node, NodeType @@ -22,7 +22,8 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]): super().__init__(coordinator) self._node_id = node.node_id mac = coordinator.config_entry.unique_id - assert mac is not None + if TYPE_CHECKING: + assert mac is not None device_info = DeviceInfo( identifiers={(DOMAIN, f"{mac}_{node.node_id}")}, manufacturer="Duco", diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 7ed85c31a643..b6963ad8a761 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -10,6 +10,9 @@ from duco_connectivity import ( ApiEndpointInfo, ApiInfo, BoardInfo, + ConfigNode, + ConfigNodeOverview, + ConfigValueString, DiagComponent, KnownActionName, LanInfo, @@ -103,6 +106,19 @@ def load_nodes_fixture(filename: str) -> list[Node]: return [_node_from_dict(node) for node in load_json_array_fixture(filename, DOMAIN)] +def node_configs_from_nodes(nodes: list[Node]) -> ConfigNodeOverview: + """Build node config names from node fixtures.""" + return ConfigNodeOverview( + nodes=[ + ConfigNode( + node_id=node.node_id, + name=ConfigValueString(node.general.name), + ) + for node in nodes + ] + ) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -236,6 +252,7 @@ def mock_duco_client( client.async_get_board_info.return_value = mock_board_info client.async_get_lan_info.return_value = mock_lan_info client.async_get_nodes.return_value = mock_nodes + client.async_get_node_configs.return_value = node_configs_from_nodes(mock_nodes) client.async_get_node_actions.return_value = mock_node_actions client.async_get_time_filter_remaining.return_value = 180 client.async_get_diagnostics.return_value = [ diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index f0807955a020..24f906507d37 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -1,9 +1,13 @@ """Tests for the Duco integration setup.""" +from datetime import timedelta from unittest.mock import ANY, AsyncMock, patch from duco_connectivity import ( BoardInfo, + ConfigNode, + ConfigNodeOverview, + ConfigValueString, DiagComponent, DucoConnectionError, DucoError, @@ -12,16 +16,42 @@ from duco_connectivity import ( Node, NodeListActionItemList, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import TEST_HOST, TEST_MAC, UNSUPPORTED_BOARD_INFOS +from .conftest import ( + TEST_HOST, + TEST_MAC, + UNSUPPORTED_BOARD_INFOS, + node_configs_from_nodes, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + + +def _get_duco_node_device(device_registry: dr.DeviceRegistry) -> dr.DeviceEntry: + """Return the primary Duco node device used in setup tests.""" + device = device_registry.async_get_device(identifiers={("duco", f"{TEST_MAC}_1")}) + assert device is not None + return device + + +def _node_configs_with_primary_name( + mock_nodes: list[Node], + primary_name: str, +) -> ConfigNodeOverview: + """Return node configs with a custom name for the primary Duco node.""" + return ConfigNodeOverview( + nodes=[ + ConfigNode(node_id=1, name=ConfigValueString(primary_name)), + *node_configs_from_nodes(mock_nodes[1:]).nodes, + ] + ) @pytest.mark.parametrize( @@ -128,6 +158,27 @@ async def test_setup_entry_ignores_lan_info_failures( assert mock_config_entry.state is ConfigEntryState.LOADED +async def test_setup_entry_ignores_node_name_config_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup falls back to API node names when node config fetch fails.""" + mock_duco_client.async_get_node_configs.side_effect = DucoError("node config error") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device = _get_duco_node_device(device_registry) + assert device.name == mock_nodes[0].general.name + assert mock_duco_client.async_get_node_configs.call_count == 1 + + @pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS) async def test_setup_entry_unsupported_board_info( hass: HomeAssistant, @@ -243,3 +294,62 @@ async def test_setup_entry_creates_http_client( session=ANY, host=TEST_HOST, ) + + +async def test_setup_entry_uses_configured_node_name( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup uses the configurable Duco node name.""" + mock_duco_client.async_get_node_configs.return_value = ( + _node_configs_with_primary_name(mock_nodes, "Kitchen") + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = _get_duco_node_device(device_registry) + assert device.name == "Kitchen" + assert mock_duco_client.async_get_node_configs.call_count == 1 + + +async def test_node_name_refresh_updates_device_registry_name( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test Duco node names update on reload, not during periodic polling.""" + mock_duco_client.async_get_node_configs.side_effect = [ + _node_configs_with_primary_name(mock_nodes, "Kitchen"), + _node_configs_with_primary_name(mock_nodes, "Living Room"), + ] + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = _get_duco_node_device(device_registry) + assert device.name == "Kitchen" + assert mock_duco_client.async_get_node_configs.call_count == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device = _get_duco_node_device(device_registry) + assert device.name == "Kitchen" + assert mock_duco_client.async_get_node_configs.call_count == 1 + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = _get_duco_node_device(device_registry) + assert device.name == "Living Room" + assert mock_duco_client.async_get_node_configs.call_count == 2 From f06f0f547fd10e5db18049ccf876e7a18e984055 Mon Sep 17 00:00:00 2001 From: TimL Date: Tue, 14 Jul 2026 01:43:23 +1000 Subject: [PATCH 560/707] Resolve SMLIGHT radio entity names based on types (#176265) --- homeassistant/components/smlight/button.py | 11 ++- homeassistant/components/smlight/const.py | 5 ++ homeassistant/components/smlight/sensor.py | 5 +- homeassistant/components/smlight/strings.json | 9 ++ homeassistant/components/smlight/update.py | 24 +++++- tests/components/smlight/conftest.py | 8 ++ tests/components/smlight/test_button.py | 82 ++++++++++++------- tests/components/smlight/test_sensor.py | 17 ++-- tests/components/smlight/test_update.py | 37 +++++++++ 9 files changed, 151 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 6f8ea41980d2..3e19b4690b7d 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, ZWAVE_TYPES from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity @@ -129,6 +129,15 @@ class SmButton(SmEntity, ButtonEntity): button = f"_{idx}" if idx else "" self._attr_unique_id = f"{coordinator.unique_id}-{description.key}{button}" + if ( + idx < len(coordinator.data.info.radios) + and coordinator.data.info.radios[idx].zb_type in ZWAVE_TYPES + ): + if description.key == "zigbee_restart": + self._attr_translation_key = "z_wave_restart" + elif description.key == "zigbee_flash_mode": + self._attr_translation_key = "z_wave_flash_mode" + @override async def async_press(self) -> None: """Trigger button press.""" diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 60acd7cdf883..3dc91ebd312e 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -4,6 +4,8 @@ from datetime import timedelta from enum import StrEnum import logging +from pysmlight.const import ZB_TYPES + DOMAIN = "smlight" ATTR_MANUFACTURER = "SMLIGHT" @@ -26,3 +28,6 @@ class BLEScannerMode(StrEnum): AUTO = "auto" ACTIVE = "active" PASSIVE = "passive" + + +ZWAVE_TYPES = tuple(k for k, v in ZB_TYPES.items() if v.lower().startswith("zwave")) diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 5335e0aec9ba..69927ddf0668 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import UPTIME_DEVIATION +from .const import UPTIME_DEVIATION, ZWAVE_TYPES from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity @@ -159,7 +159,8 @@ async def async_setup_entry( entities.extend( SmInfoSensorEntity(coordinator, RADIO_INFO, idx) - for idx, _ in enumerate(coordinator.data.info.radios) + for idx, radio in enumerate(coordinator.data.info.radios) + if radio.zb_type not in ZWAVE_TYPES ) if coordinator.data.sensors.zb_temp2 is not None: diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 19c6623dcc3d..6de1f48ecaed 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -84,6 +84,12 @@ "reconnect_zigbee_router": { "name": "Reconnect Zigbee router" }, + "z_wave_flash_mode": { + "name": "Z-Wave flash mode" + }, + "z_wave_restart": { + "name": "Z-Wave restart" + }, "zigbee_flash_mode": { "name": "Zigbee flash mode" }, @@ -160,6 +166,9 @@ "core_update": { "name": "Core firmware" }, + "z_wave_update": { + "name": "Z-Wave firmware" + }, "zigbee_update": { "name": "Zigbee firmware" } diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 899ccfaa6468..37b738ab47e8 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, ZWAVE_TYPES from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity @@ -77,7 +77,8 @@ async def async_setup_entry( entities.extend( SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) - for idx, _ in enumerate(radios) + for idx, radio in enumerate(radios) + if radio.zb_type != -1 ) async_add_entities(entities) @@ -114,6 +115,14 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self._unload: list[Callable] = [] self.idx = idx + if ( + (data := coordinator.data) + and idx < len(data.info.radios) + and data.info.radios[idx].zb_type in ZWAVE_TYPES + ): + if description.key == "zigbee_update": + self._attr_translation_key = "z_wave_update" + @override async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -173,7 +182,16 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def release_notes(self) -> str | None: """Return release notes for firmware.""" if "zigbee" in self.entity_description.key: - notes = f"### {'ZNP' if self.idx else 'EZSP'} Firmware\n\n" + radio_desc = "Zigbee" + if (data := self.coordinator.data) and self.idx < len(data.info.radios): + radio = data.info.radios[self.idx] + if radio.zb_type in ZWAVE_TYPES: + radio_desc = "Z-Wave" + elif radio.zb_hw and "EFR32" in radio.zb_hw: + radio_desc = "EZSP" + elif radio.zb_hw and "CC2" in radio.zb_hw: + radio_desc = "ZNP" + notes = f"### {radio_desc} Firmware\n\n" else: notes = "### Core Firmware\n\n" diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 844568d5fd8d..792c3d8dcadb 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from pysmlight import Radio from pysmlight.exceptions import SmlightAuthError from pysmlight.models import BleFeatures from pysmlight.sse import sseClient @@ -152,7 +153,14 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: MOCK_ULTIMA = Info( MAC="AA:BB:CC:DD:EE:FF", model="SLZB-Ultima3", + addons={"zwave": True}, ble=BleFeatures(ble_enabled=True, proxy_enabled=True), + u_device=True, + radios=[ + Radio(zb_type=0, chip_index=0), + Radio(zb_type=1, chip_index=1), + Radio(zb_type=5, chip_index=2), + ], ) diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py index ac66324e8938..b51df08cfffd 100644 --- a/tests/components/smlight/test_button.py +++ b/tests/components/smlight/test_button.py @@ -156,23 +156,15 @@ async def test_remove_router_reconnect( ("zigbee_flash_mode", 1), ], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") async def test_multi_radio_buttons_u_device( hass: HomeAssistant, entity_registry: er.EntityRegistry, key: str, idx: int, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, ) -> None: """Test per-radio restart and flash mode buttons on a u-device.""" - mock_smlight_client.get_info.side_effect = None - info = Info.from_dict( - await async_load_json_object_fixture(hass, "info-MR1.json", DOMAIN) - ) - info.u_device = True - mock_smlight_client.get_info.return_value = info - await setup_integration(hass, mock_config_entry) unique_id_suffix = f"_{idx}" if idx else "" @@ -200,16 +192,9 @@ async def test_multi_radio_press_calls_idx( method: str, idx: int, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test pressing per-radio buttons passes the correct idx to the command.""" - mock_smlight_client.get_info.side_effect = None - info = Info.from_dict( - await async_load_json_object_fixture(hass, "info-MR1.json", DOMAIN) - ) - info.u_device = True - mock_smlight_client.get_info.return_value = info - await setup_integration(hass, mock_config_entry) unique_id_suffix = f"_{idx}" if idx else "" @@ -217,7 +202,7 @@ async def test_multi_radio_press_calls_idx( entity_id = entity_registry.async_get_entity_id(BUTTON_DOMAIN, DOMAIN, unique_id) assert entity_id is not None - mock_method = getattr(mock_smlight_client.cmds, method) + mock_method = getattr(mock_ultima_client.cmds, method) await hass.services.async_call( BUTTON_DOMAIN, @@ -249,29 +234,66 @@ async def test_multi_radio_buttons_shared_non_u_device( ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") async def test_router_button_with_3_radios( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, ) -> None: """Test creation of router buttons for device with 3 radios.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info( - MAC="AA:BB:CC:DD:EE:FF", - radios=[ - Radio(zb_type=0, chip_index=0), - Radio(zb_type=1, chip_index=1), - Radio(zb_type=0, chip_index=2), - ], - ) await setup_integration(hass, mock_config_entry) entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entities) == 4 + router_entities = [e for e in entities if "reconnect_zigbee_router" in e.unique_id] + assert len(router_entities) == 1 entity = entity_registry.async_get("button.mock_title_reconnect_zigbee_router") assert entity is not None + assert entity.unique_id == "aa:bb:cc:dd:ee:ff-reconnect_zigbee_router_1" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") +async def test_zwave_radio_naming( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Z-Wave radio (index 2) button entity naming.""" + await setup_integration(hass, mock_config_entry) + + restart_btn = hass.states.get("button.mock_title_z_wave_restart") + assert restart_btn is not None + assert restart_btn.name == "Mock Title Z-Wave restart" + + flash_btn = hass.states.get("button.mock_title_z_wave_flash_mode") + assert flash_btn is not None + assert flash_btn.name == "Mock Title Z-Wave flash mode" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_zwave_radio_naming_mr10( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test Z-Wave radio (index 1) button entity naming for MR10/MRW10.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-MRW10", + u_device=True, + radios=[ + Radio(zb_type=0, chip_index=0), + Radio(zb_type=5, chip_index=1), + ], + ) + await setup_integration(hass, mock_config_entry) + + restart_btn = hass.states.get("button.mock_title_z_wave_restart") + assert restart_btn is not None + assert restart_btn.name == "Mock Title Z-Wave restart" + + flash_btn = hass.states.get("button.mock_title_z_wave_flash_mode") + assert flash_btn is not None + assert flash_btn.name == "Mock Title Z-Wave flash mode" diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index 75bd5861f819..7f95c5bb1cb9 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -14,11 +14,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import setup_integration -from tests.common import ( - MockConfigEntry, - async_load_json_object_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, snapshot_platform pytestmark = [ pytest.mark.usefixtures( @@ -95,16 +91,12 @@ async def test_zigbee2_temp_sensor( assert state.state == "20.45" +@pytest.mark.usefixtures("mock_ultima_client") async def test_zigbee_type_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, ) -> None: - """Test for zigbee type sensor with second radio.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info.from_dict( - await async_load_json_object_fixture(hass, "info-MR1.json", DOMAIN) - ) + """Test for zigbee type sensor with multiple radios.""" await setup_integration(hass, mock_config_entry) state = hass.states.get("sensor.mock_title_zigbee_type") @@ -115,6 +107,9 @@ async def test_zigbee_type_sensors( assert state assert state.state == "router" + # Radio 3 is Z-Wave, so no Zigbee-type sensor should be created + assert hass.states.get("sensor.mock_title_zigbee_type_3") is None + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_psram_usage_sensor( diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index acd9cfe197e3..1945584df820 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -391,3 +391,40 @@ async def test_update_blank_release_notes( result = await ws_client.receive_json() await hass.async_block_till_done() assert result["result"] is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") +async def test_zwave_update_naming( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Z-Wave radio (index 2) update entity naming.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("update.mock_title_z_wave_firmware") + assert state is not None + assert state.name == "Mock Title Z-Wave firmware" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_zwave_update_naming_mr10( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test Z-Wave radio (index 1) update entity naming for MR10/MRW10.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-MRW10", + u_device=True, + radios=[ + Radio(zb_type=0, chip_index=0), + Radio(zb_type=5, chip_index=1), + ], + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("update.mock_title_z_wave_firmware") + assert state is not None + assert state.name == "Mock Title Z-Wave firmware" From 6847ae075859a4c3256cbf595abaf46a43a4ea0f Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Mon, 13 Jul 2026 23:51:26 +0800 Subject: [PATCH 561/707] Move iZone discovery unit tests to test_discovery.py (#176253) Co-authored-by: Cursor --- tests/components/izone/test_config_flow.py | 323 +------------------- tests/components/izone/test_discovery.py | 335 +++++++++++++++++++++ 2 files changed, 338 insertions(+), 320 deletions(-) create mode 100644 tests/components/izone/test_discovery.py diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index e58a0c30e6f9..2d5045edcc99 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -2,14 +2,14 @@ from collections.abc import Generator from types import SimpleNamespace -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant import config_entries from homeassistant.components.izone import config_flow, discovery as izone_discovery -from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.components.izone.const import DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -18,7 +18,6 @@ from .conftest import ( async_install_discovery_service, async_load_yaml_exclude, create_mock_controller, - create_mock_discovery_service, patch_discovered_controllers, ) @@ -1012,195 +1011,6 @@ async def test_async_setup_starts_import_flow(hass: HomeAssistant) -> None: mock_create_task.assert_called_once() -async def test_async_start_discovery_service_stops_on_home_assistant_stop( - hass: HomeAssistant, - mock_pizone_discovery_service: Mock, -) -> None: - """Test discovery service is stopped on Home Assistant shutdown.""" - with ( - patch( - "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", - return_value=Mock(), - ), - patch( - "homeassistant.components.izone.discovery.pizone.discovery", - return_value=mock_pizone_discovery_service, - ), - ): - await izone_discovery.async_start_discovery_service(hass) - - assert DATA_DISCOVERY_SERVICE in hass.data - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - - mock_pizone_discovery_service.start_discovery.assert_awaited_once() - mock_pizone_discovery_service.close.assert_awaited_once() - assert DATA_DISCOVERY_SERVICE not in hass.data - - -async def test_async_maybe_stop_keeps_running_when_actionable_flow_exists( - hass: HomeAssistant, -) -> None: - """Discovery should stay running while an actionable iZone flow is in progress.""" - service = create_mock_discovery_service() - hass.data[DATA_DISCOVERY_SERVICE] = service - - with ( - patch.object( - hass.config_entries.flow, - "async_progress_by_handler", - return_value=[{"context": {"source": config_entries.SOURCE_USER}}], - ), - patch( - "homeassistant.components.izone.discovery.async_stop_discovery_service", - new=AsyncMock(), - ) as mock_stop, - ): - await izone_discovery.async_maybe_stop_discovery_service(hass) - - mock_stop.assert_not_awaited() - service.async_schedule_idle_stop.assert_called_once() - - -async def test_async_maybe_stop_keeps_running_when_actionable_entry_exists( - hass: HomeAssistant, -) -> None: - """Discovery should stay running while an enabled, non-ignored entry exists.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="000000001", - source=config_entries.SOURCE_USER, - data={}, - ).add_to_hass(hass) - - service = create_mock_discovery_service() - hass.data[DATA_DISCOVERY_SERVICE] = service - - with patch( - "homeassistant.components.izone.discovery.async_stop_discovery_service", - new=AsyncMock(), - ) as mock_stop: - await izone_discovery.async_maybe_stop_discovery_service(hass) - - mock_stop.assert_not_awaited() - service.async_schedule_idle_stop.assert_called_once() - - -async def test_async_maybe_stop_stops_when_only_disabled_entry_matches_controller( - hass: HomeAssistant, -) -> None: - """Discovery should stop when only disabled/ignored controllers remain.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="000000001", - source=config_entries.SOURCE_USER, - disabled_by=config_entries.ConfigEntryDisabler.USER, - data={}, - ).add_to_hass(hass) - - service = create_mock_discovery_service(create_mock_controller("000000001")) - hass.data[DATA_DISCOVERY_SERVICE] = service - - with patch( - "homeassistant.components.izone.discovery.async_stop_discovery_service", - new=AsyncMock(), - ) as mock_stop: - await izone_discovery.async_maybe_stop_discovery_service(hass) - - mock_stop.assert_awaited_once_with(hass) - service.async_schedule_idle_stop.assert_not_called() - - -async def test_async_discover_controllers_starts_shared_service_when_missing( - hass: HomeAssistant, -) -> None: - """Starting discovery without refresh does not trigger extra wait/rescan work.""" - controller = create_mock_controller(device_ip="192.0.2.3") - service = create_mock_discovery_service(controller) - - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - return_value=service, - ) as mock_start: - controllers = await izone_discovery.async_discover_controllers(hass) - - assert list(controllers) == ["000000001"] - mock_start.assert_awaited_once() - service.pi_disco.fetch_controller.assert_not_awaited() - service.pi_disco.fetch_controllers.assert_awaited_once_with() - - -async def test_async_discover_controllers_refresh_after_start_calls_fetch_controllers( - hass: HomeAssistant, -) -> None: - """Refresh after starting discovery delegates to fetch_controllers with timeout.""" - controller = create_mock_controller(device_ip="192.0.2.3") - service = create_mock_discovery_service(controller) - - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - return_value=service, - ) as mock_start: - controllers = await izone_discovery.async_discover_controllers( - hass, refresh=True - ) - - assert list(controllers) == ["000000001"] - mock_start.assert_awaited_once() - service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) - - -async def test_async_discover_controllers_refresh_calls_fetch_controllers( - hass: HomeAssistant, -) -> None: - """Refresh without UID delegates to fetch_controllers with timeout.""" - service = create_mock_discovery_service() - hass.data[DATA_DISCOVERY_SERVICE] = service - - controllers = await izone_discovery.async_discover_controllers(hass, refresh=True) - - assert controllers == {} - service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) - - -async def test_async_discover_controllers_waits_for_requested_uid( - hass: HomeAssistant, -) -> None: - """Refresh with wait_for_uid calls fetch_controller and returns all controllers.""" - service = create_mock_discovery_service() - hass.data[DATA_DISCOVERY_SERVICE] = service - requested = create_mock_controller("000000777", "192.0.2.77") - - async def _fetch_and_add(uid: str, timeout: float | None = None) -> None: - service.pi_disco.controllers[uid] = requested - - service.pi_disco.fetch_controller.side_effect = _fetch_and_add - - controllers = await izone_discovery.async_discover_controllers( - hass, - refresh=True, - wait_for_uid="000000777", - ) - - assert controllers == {requested.device_uid: requested} - service.pi_disco.fetch_controller.assert_awaited_once_with("000000777", timeout=ANY) - - -async def test_async_discover_controllers_returns_empty_when_start_fails( - hass: HomeAssistant, -) -> None: - """Startup errors while creating discovery are propagated to the caller.""" - with ( - patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - side_effect=OSError, - ), - pytest.raises(OSError), - ): - await izone_discovery.async_discover_controllers(hass, refresh=True) - - def test_flow_uid_for_matching_returns_none_when_no_uid() -> None: """Flow UID extraction returns None when context has no unique_id.""" flow = SimpleNamespace(context={}, init_data=None) @@ -1758,33 +1568,6 @@ def test_discovery_listener_methods_dispatch_expected_signals( ] -def test_controller_discovered_dispatches_signal_and_reschedules_idle_stop( - hass: HomeAssistant, -) -> None: - """Discovered controller should dispatch signal and cancel prior idle-stop handle.""" - service = izone_discovery.DiscoveryService(hass) - previous_handle = Mock() - new_handle = Mock() - service._idle_stop_handle = previous_handle - controller = create_mock_controller("000000001", "192.0.2.1") - - with ( - patch.object(hass.loop, "call_later", return_value=new_handle), - patch( - "homeassistant.components.izone.discovery.async_dispatcher_send" - ) as mock_send, - ): - service.controller_discovered(controller) - - previous_handle.cancel.assert_called_once() - assert service._idle_stop_handle is new_handle - mock_send.assert_called_once_with( - hass, - izone_discovery.DISPATCH_CONTROLLER_DISCOVERED, - controller, - ) - - async def test_start_discovery_listener_forwards_discovered_controller_to_flow( hass: HomeAssistant, mock_pizone_discovery_service: Mock, @@ -1821,103 +1604,3 @@ async def test_start_discovery_listener_forwards_discovered_controller_to_flow( captured_listener(controller) mock_note.assert_called_once_with(hass, controller) - - -async def test_is_ignored_or_excluded_uid_returns_true_for_yaml_exclude( - hass: HomeAssistant, -) -> None: - """UIDs listed in YAML exclude are treated as ignored/excluded.""" - await async_load_yaml_exclude(hass, "000000009") - - assert izone_discovery._async_is_ignored_or_excluded_uid(hass, "000000009") is True - - -async def test_async_maybe_stop_returns_when_service_not_started( - hass: HomeAssistant, -) -> None: - """No-op when maybe-stop is called without a discovery service instance.""" - await izone_discovery.async_maybe_stop_discovery_service(hass) - - -async def test_async_start_discovery_service_returns_existing_instance( - hass: HomeAssistant, -) -> None: - """Starting discovery returns existing service when already running.""" - existing = Mock() - hass.data[DATA_DISCOVERY_SERVICE] = existing - - disco = await izone_discovery.async_start_discovery_service(hass) - - assert disco is existing - - -async def test_async_maybe_stop_stops_when_no_controllers_remain( - hass: HomeAssistant, -) -> None: - """Discovery stops when no controllers are tracked and nothing is actionable.""" - service = create_mock_discovery_service() - hass.data[DATA_DISCOVERY_SERVICE] = service - - with ( - patch.object( - hass.config_entries.flow, - "async_progress_by_handler", - return_value=[], - ), - patch( - "homeassistant.components.izone.discovery.async_stop_discovery_service", - new=AsyncMock(), - ) as mock_stop, - ): - await izone_discovery.async_maybe_stop_discovery_service(hass) - - mock_stop.assert_awaited_once_with(hass) - - -async def test_async_maybe_stop_keeps_running_when_controller_not_ignored( - hass: HomeAssistant, -) -> None: - """Discovery remains active if at least one discovered controller is still actionable.""" - service = create_mock_discovery_service(create_mock_controller("000000001")) - hass.data[DATA_DISCOVERY_SERVICE] = service - - with ( - patch.object( - hass.config_entries.flow, - "async_progress_by_handler", - return_value=[], - ), - patch( - "homeassistant.components.izone.discovery.async_stop_discovery_service", - new=AsyncMock(), - ) as mock_stop, - ): - await izone_discovery.async_maybe_stop_discovery_service(hass) - - mock_stop.assert_not_awaited() - service.async_schedule_idle_stop.assert_called_once() - - -async def test_async_stop_discovery_service_returns_when_not_started( - hass: HomeAssistant, -) -> None: - """Stop is a no-op if discovery service was never started.""" - await izone_discovery.async_stop_discovery_service(hass) - - -async def test_async_stop_discovery_service_clears_stop_listener( - hass: HomeAssistant, -) -> None: - """Stop should remove the stop listener when it exists.""" - service = Mock() - stop_listener = Mock() - service.remove_stop_listener = stop_listener - service.remove_config_flow_listener = None - service.async_cancel_idle_stop = Mock() - service.pi_disco.close = AsyncMock() - hass.data[DATA_DISCOVERY_SERVICE] = service - - await izone_discovery.async_stop_discovery_service(hass) - - stop_listener.assert_called_once() - assert service.remove_stop_listener is None diff --git a/tests/components/izone/test_discovery.py b/tests/components/izone/test_discovery.py new file mode 100644 index 000000000000..0639ea92796e --- /dev/null +++ b/tests/components/izone/test_discovery.py @@ -0,0 +1,335 @@ +"""Tests for iZone discovery service.""" + +from unittest.mock import ANY, AsyncMock, Mock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.izone import discovery as izone_discovery +from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from .conftest import ( + async_load_yaml_exclude, + create_mock_controller, + create_mock_discovery_service, +) + +from tests.common import MockConfigEntry + + +async def test_async_start_discovery_service_stops_on_home_assistant_stop( + hass: HomeAssistant, + mock_pizone_discovery_service: Mock, +) -> None: + """Test discovery service is stopped on Home Assistant shutdown.""" + with ( + patch( + "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", + return_value=Mock(), + ), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=mock_pizone_discovery_service, + ), + ): + await izone_discovery.async_start_discovery_service(hass) + + assert DATA_DISCOVERY_SERVICE in hass.data + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_pizone_discovery_service.start_discovery.assert_awaited_once() + mock_pizone_discovery_service.close.assert_awaited_once() + assert DATA_DISCOVERY_SERVICE not in hass.data + + +async def test_async_maybe_stop_keeps_running_when_actionable_flow_exists( + hass: HomeAssistant, +) -> None: + """Discovery should stay running while an actionable iZone flow is in progress.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[{"context": {"source": config_entries.SOURCE_USER}}], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_maybe_stop_keeps_running_when_actionable_entry_exists( + hass: HomeAssistant, +) -> None: + """Discovery should stay running while an enabled, non-ignored entry exists.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_USER, + data={}, + ).add_to_hass(hass) + + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop: + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_maybe_stop_stops_when_only_disabled_entry_matches_controller( + hass: HomeAssistant, +) -> None: + """Discovery should stop when only disabled/ignored controllers remain.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_USER, + disabled_by=config_entries.ConfigEntryDisabler.USER, + data={}, + ).add_to_hass(hass) + + service = create_mock_discovery_service(create_mock_controller("000000001")) + hass.data[DATA_DISCOVERY_SERVICE] = service + + with patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop: + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_awaited_once_with(hass) + service.async_schedule_idle_stop.assert_not_called() + + +async def test_async_discover_controllers_starts_shared_service_when_missing( + hass: HomeAssistant, +) -> None: + """Starting discovery without refresh does not trigger extra wait/rescan work.""" + controller = create_mock_controller(device_ip="192.0.2.3") + service = create_mock_discovery_service(controller) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + return_value=service, + ) as mock_start: + controllers = await izone_discovery.async_discover_controllers(hass) + + assert list(controllers) == ["000000001"] + mock_start.assert_awaited_once() + service.pi_disco.fetch_controller.assert_not_awaited() + service.pi_disco.fetch_controllers.assert_awaited_once_with() + + +async def test_async_discover_controllers_refresh_after_start_calls_fetch_controllers( + hass: HomeAssistant, +) -> None: + """Refresh after starting discovery delegates to fetch_controllers with timeout.""" + controller = create_mock_controller(device_ip="192.0.2.3") + service = create_mock_discovery_service(controller) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + return_value=service, + ) as mock_start: + controllers = await izone_discovery.async_discover_controllers( + hass, refresh=True + ) + + assert list(controllers) == ["000000001"] + mock_start.assert_awaited_once() + service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) + + +async def test_async_discover_controllers_refresh_calls_fetch_controllers( + hass: HomeAssistant, +) -> None: + """Refresh without UID delegates to fetch_controllers with timeout.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + controllers = await izone_discovery.async_discover_controllers(hass, refresh=True) + + assert controllers == {} + service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) + + +async def test_async_discover_controllers_waits_for_requested_uid( + hass: HomeAssistant, +) -> None: + """Refresh with wait_for_uid calls fetch_controller and returns all controllers.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + requested = create_mock_controller("000000777", "192.0.2.77") + + async def _fetch_and_add(uid: str, timeout: float | None = None) -> None: + service.pi_disco.controllers[uid] = requested + + service.pi_disco.fetch_controller.side_effect = _fetch_and_add + + controllers = await izone_discovery.async_discover_controllers( + hass, + refresh=True, + wait_for_uid="000000777", + ) + + assert controllers == {requested.device_uid: requested} + service.pi_disco.fetch_controller.assert_awaited_once_with("000000777", timeout=ANY) + + +async def test_async_discover_controllers_returns_empty_when_start_fails( + hass: HomeAssistant, +) -> None: + """Startup errors while creating discovery are propagated to the caller.""" + with ( + patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ), + pytest.raises(OSError), + ): + await izone_discovery.async_discover_controllers(hass, refresh=True) + + +def test_controller_discovered_dispatches_signal_and_reschedules_idle_stop( + hass: HomeAssistant, +) -> None: + """Discovered controller should dispatch signal and cancel prior idle-stop handle.""" + service = izone_discovery.DiscoveryService(hass) + previous_handle = Mock() + new_handle = Mock() + service._idle_stop_handle = previous_handle + controller = create_mock_controller("000000001", "192.0.2.1") + + with ( + patch.object(hass.loop, "call_later", return_value=new_handle), + patch( + "homeassistant.components.izone.discovery.async_dispatcher_send" + ) as mock_send, + ): + service.controller_discovered(controller) + + previous_handle.cancel.assert_called_once() + assert service._idle_stop_handle is new_handle + mock_send.assert_called_once_with( + hass, + izone_discovery.DISPATCH_CONTROLLER_DISCOVERED, + controller, + ) + + +async def test_is_ignored_or_excluded_uid_returns_true_for_yaml_exclude( + hass: HomeAssistant, +) -> None: + """UIDs listed in YAML exclude are treated as ignored/excluded.""" + await async_load_yaml_exclude(hass, "000000009") + + assert izone_discovery._async_is_ignored_or_excluded_uid(hass, "000000009") is True + + +async def test_async_maybe_stop_returns_when_service_not_started( + hass: HomeAssistant, +) -> None: + """No-op when maybe-stop is called without a discovery service instance.""" + await izone_discovery.async_maybe_stop_discovery_service(hass) + + +async def test_async_start_discovery_service_returns_existing_instance( + hass: HomeAssistant, +) -> None: + """Starting discovery returns existing service when already running.""" + existing = Mock() + hass.data[DATA_DISCOVERY_SERVICE] = existing + + disco = await izone_discovery.async_start_discovery_service(hass) + + assert disco is existing + + +async def test_async_maybe_stop_stops_when_no_controllers_remain( + hass: HomeAssistant, +) -> None: + """Discovery stops when no controllers are tracked and nothing is actionable.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_awaited_once_with(hass) + + +async def test_async_maybe_stop_keeps_running_when_controller_not_ignored( + hass: HomeAssistant, +) -> None: + """Discovery remains active if at least one discovered controller is still actionable.""" + service = create_mock_discovery_service(create_mock_controller("000000001")) + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_stop_discovery_service_returns_when_not_started( + hass: HomeAssistant, +) -> None: + """Stop is a no-op if discovery service was never started.""" + await izone_discovery.async_stop_discovery_service(hass) + + +async def test_async_stop_discovery_service_clears_stop_listener( + hass: HomeAssistant, +) -> None: + """Stop should remove the stop listener when it exists.""" + service = Mock() + stop_listener = Mock() + service.remove_stop_listener = stop_listener + service.remove_config_flow_listener = None + service.async_cancel_idle_stop = Mock() + service.pi_disco.close = AsyncMock() + hass.data[DATA_DISCOVERY_SERVICE] = service + + await izone_discovery.async_stop_discovery_service(hass) + + stop_listener.assert_called_once() + assert service.remove_stop_listener is None From eda695290f971a70cf66a0bad14a0518626712e5 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Mon, 13 Jul 2026 16:52:01 +0100 Subject: [PATCH 562/707] Remove Ohme energy sensor (#174664) --- homeassistant/components/ohme/sensor.py | 10 --- tests/components/ohme/conftest.py | 1 - .../ohme/snapshots/test_sensor.ambr | 61 ------------------- tests/components/ohme/test_sensor.py | 10 +-- 4 files changed, 5 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 86d7d964c3a5..1bc5c85925e4 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfElectricCurrent, UnitOfElectricPotential, - UnitOfEnergy, UnitOfPower, ) from homeassistant.core import HomeAssistant @@ -60,15 +59,6 @@ SENSORS = [ state_class=SensorStateClass.MEASUREMENT, value_fn=lambda client: client.power.watts, ), - OhmeSensorDescription( - key="energy", - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=1, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda client: client.energy, - ), OhmeSensorDescription( key="voltage", device_class=SensorDeviceClass.VOLTAGE, diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 2a830a5bfc8a..699f9e038a4c 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -63,7 +63,6 @@ def mock_client(): client.ct_connected = True client.cap_available = True client.cap_enabled = True - client.energy = 1000 client.device_info = { "name": "Ohme Home Pro", "model": "Home Pro", diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index a4e0ece11629..597aff8c7dad 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -107,67 +107,6 @@ 'state': '0', }) # --- -# name: test_sensors[sensor.ohme_home_pro_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ohme_home_pro_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Energy', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy', - 'platform': 'ohme', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'chargerid_energy', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.ohme_home_pro_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'energy', - : 'Ohme Home Pro Energy', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.ohme_home_pro_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.0', - }) -# --- # name: test_sensors[sensor.ohme_home_pro_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py index b7c8f82aafc1..edab5dc5988e 100644 --- a/tests/components/ohme/test_sensor.py +++ b/tests/components/ohme/test_sensor.py @@ -41,15 +41,15 @@ async def test_sensors_unavailable( """Test that sensors show as unavailable after a coordinator failure.""" await setup_integration(hass, mock_config_entry) - state = hass.states.get("sensor.ohme_home_pro_energy") - assert state.state == "1.0" + state = hass.states.get("sensor.ohme_home_pro_status") + assert state.state == "charging" mock_client.async_get_charge_session.side_effect = ApiException freezer.tick(timedelta(seconds=60)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.ohme_home_pro_energy") + state = hass.states.get("sensor.ohme_home_pro_status") assert state.state == STATE_UNAVAILABLE mock_client.async_get_charge_session.side_effect = None @@ -57,5 +57,5 @@ async def test_sensors_unavailable( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.ohme_home_pro_energy") - assert state.state == "1.0" + state = hass.states.get("sensor.ohme_home_pro_status") + assert state.state == "charging" From f54781e3b2574545aaa7deeec05a38667308cdef Mon Sep 17 00:00:00 2001 From: TimL Date: Tue, 14 Jul 2026 02:05:51 +1000 Subject: [PATCH 563/707] Add SMLIGHT to Thread known brands (#176415) --- homeassistant/components/thread/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 850c5ec37ccc..a6ddb29099b5 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -36,6 +36,7 @@ KNOWN_BRANDS: dict[str | None, str] = { "OpenThread": "openthread", "Samsung": "samsung", "SmartThings": "smartthings", + "SMLIGHT": "smlight", "Yeelight": "yeelight", } THREAD_TYPE = "_meshcop._udp.local." From 912567b9edde1077f6bf53098b1395eddbcb2e69 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:23:06 +0100 Subject: [PATCH 564/707] Update infrared-protocols to 7.0.0 (#176426) --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index f22bfd5f738d..7f699ac774d1 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==6.6.1"] + "requirements": ["infrared-protocols==7.0.0"] } diff --git a/requirements.txt b/requirements.txt index 29e5c465204a..cb713db0f214 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==6.6.1 +infrared-protocols==7.0.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index fc59191372d2..766f784bb585 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1368,7 +1368,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==6.6.1 +infrared-protocols==7.0.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 From be7032708c966a73eaf8ea2b695b43a4703a06d2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Jul 2026 19:01:34 +0200 Subject: [PATCH 565/707] Quality improvements for Mikrotik (#176430) --- homeassistant/components/mikrotik/coordinator.py | 4 +--- homeassistant/components/mikrotik/entity.py | 1 + homeassistant/components/mikrotik/icons.json | 6 +++--- homeassistant/components/mikrotik/sensor.py | 12 +----------- homeassistant/components/mikrotik/utils.py | 4 +--- 5 files changed, 7 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 96e935622c4d..8855df935101 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -234,9 +234,7 @@ class MikrotikData: ) -> list[dict[str, Any]]: """Retrieve data from Mikrotik API.""" _LOGGER.debug("Running command %s", cmd) - with mikrotik_config_entry_errors( - suppress_errors=suppress_errors, host=self._host - ): + with mikrotik_config_entry_errors(suppress_errors=suppress_errors): if params: return list(self.api(cmd, **params)) return list(self.api(cmd)) diff --git a/homeassistant/components/mikrotik/entity.py b/homeassistant/components/mikrotik/entity.py index 3a0b8c79f669..13573bbfb195 100644 --- a/homeassistant/components/mikrotik/entity.py +++ b/homeassistant/components/mikrotik/entity.py @@ -40,3 +40,4 @@ class MikrotikEntity[DescriptionT: EntityDescription]( sw_version=coordinator.firmware, serial_number=self._serial, ) + self._attr_unique_id = f"{self._serial}_{description.key}" diff --git a/homeassistant/components/mikrotik/icons.json b/homeassistant/components/mikrotik/icons.json index adccb6039904..73f0b262b450 100644 --- a/homeassistant/components/mikrotik/icons.json +++ b/homeassistant/components/mikrotik/icons.json @@ -1,13 +1,13 @@ { "entity": { "sensor": { - "cpu-load": { + "cpu_load": { "default": "mdi:chip" }, - "disk-usage": { + "disk_usage": { "default": "mdi:harddisk" }, - "memory-usage": { + "memory_usage": { "default": "mdi:memory" } } diff --git a/homeassistant/components/mikrotik/sensor.py b/homeassistant/components/mikrotik/sensor.py index ecae2a82d3e5..fc3c1e48d1bf 100644 --- a/homeassistant/components/mikrotik/sensor.py +++ b/homeassistant/components/mikrotik/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from .const import HEALTH, SYSTEM -from .coordinator import _LOGGER, MikrotikConfigEntry, MikrotikDataUpdateCoordinator +from .coordinator import _LOGGER, MikrotikConfigEntry from .entity import MikrotikEntity PARALLEL_UPDATES = 0 @@ -169,16 +169,6 @@ class MikrotikSensorEntity( entity_description: MikrotikSensorEntityDescription - def __init__( - self, - coordinator: MikrotikDataUpdateCoordinator, - description: MikrotikSensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, description) - - self._attr_unique_id = f"{self._serial}_{description.key}" - @property @override def native_value(self) -> StateType | datetime: diff --git a/homeassistant/components/mikrotik/utils.py b/homeassistant/components/mikrotik/utils.py index 46fe6d0beff0..4bc50d05f7c1 100644 --- a/homeassistant/components/mikrotik/utils.py +++ b/homeassistant/components/mikrotik/utils.py @@ -16,9 +16,7 @@ from .errors import CannotConnect, LoginError @contextmanager -def mikrotik_config_entry_errors( - suppress_errors: bool = False, host: str | None = None -) -> Generator[None]: +def mikrotik_config_entry_errors(suppress_errors: bool = False) -> Generator[None]: """Handle common Mikrotik API exceptions as ConfigEntry errors.""" try: yield From 3576da27fdd502bacff774bf8dcb869ae05dd159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 13 Jul 2026 18:31:54 +0100 Subject: [PATCH 566/707] Add manually-triggered workflow for E2E testing Core images (#176249) Co-authored-by: Claude Fable 5 --- .github/workflows/e2e-tests.yml | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/e2e-tests.yml diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 000000000000..d46a16ed9bd6 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,95 @@ +name: E2E tests + +# yamllint disable-line rule:truthy +on: + workflow_dispatch: + inputs: + version: + description: "Image tag or digest to test (e.g. dev, 2026.7.1, 2026.8.0b0, sha256:0a1b2c3d…)" + default: "dev" + required: true + +env: + STARTUP_TIMEOUT_SECONDS: 300 + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version }} + cancel-in-progress: true + +jobs: + boot_check: + name: Boot check ${{ matrix.arch }} core image + if: github.repository_owner == 'home-assistant' + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runs-on: ubuntu-24.04 + - arch: aarch64 + runs-on: ubuntu-24.04-arm + env: + IMAGE: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} + BASE_URL: http://localhost:8123 + CURL_OPTS: --silent --max-time 10 + steps: + - name: Pull image + id: pull + run: | + docker pull "$IMAGE" + docker image inspect -f 'Testing {{index .RepoDigests 0}} ({{.Os}}/{{.Architecture}}), created {{.Created}}' "$IMAGE" + + - name: Start container + run: | + docker run -d --name homeassistant -p 8123:8123 "$IMAGE" + + - name: Wait for Home Assistant to start + run: | + timeout=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) + while ! curl $CURL_OPTS --fail --output /dev/null "$BASE_URL/"; do + if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then + echo "::error::Container exited before Home Assistant started" + exit 1 + fi + if [ "$SECONDS" -ge "$timeout" ]; then + echo "::error::Home Assistant did not respond on port 8123 within ${STARTUP_TIMEOUT_SECONDS}s" + exit 1 + fi + sleep 5 + done + + - name: Check frontend is served + run: | + # Pre-onboarding, / redirects to /onboarding.html; --location follows it + status=$(curl $CURL_OPTS --location --output /dev/null --write-out '%{http_code}' "$BASE_URL/") + if [ "$status" -ne 200 ]; then + echo "::error::Expected HTTP 200 from frontend, got $status" + exit 1 + fi + + - name: Check onboarding API responds + run: | + curl $CURL_OPTS --fail "$BASE_URL/api/onboarding" \ + | jq -e 'type == "array" and length > 0' + + - name: Check container is still running + run: | + if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then + echo "::error::Container is no longer running after checks" + exit 1 + fi + + - name: Dump container logs + if: always() && steps.pull.outcome == 'success' + run: docker logs homeassistant > homeassistant.log 2>&1 || true + + - name: Upload container logs + if: always() && steps.pull.outcome == 'success' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: container-logs-${{ matrix.arch }} + path: homeassistant.log From 9fee812da9e93d2d9785ba350e18c5f9262b141e Mon Sep 17 00:00:00 2001 From: Matthew Dias Date: Mon, 13 Jul 2026 12:33:09 -0500 Subject: [PATCH 567/707] Add oven light to Whirlpool (#176364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 Co-authored-by: Abílio Costa Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/whirlpool/__init__.py | 1 + homeassistant/components/whirlpool/light.py | 62 ++++++ .../components/whirlpool/strings.json | 11 ++ tests/components/whirlpool/conftest.py | 2 + .../whirlpool/snapshots/test_light.ambr | 178 ++++++++++++++++++ tests/components/whirlpool/test_light.py | 99 ++++++++++ 6 files changed, 353 insertions(+) create mode 100644 homeassistant/components/whirlpool/light.py create mode 100644 tests/components/whirlpool/snapshots/test_light.ambr create mode 100644 tests/components/whirlpool/test_light.py diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 1cdb875549c6..4f74c34e7a50 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -21,6 +21,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.LIGHT, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/whirlpool/light.py b/homeassistant/components/whirlpool/light.py new file mode 100644 index 000000000000..3314c70b39da --- /dev/null +++ b/homeassistant/components/whirlpool/light.py @@ -0,0 +1,62 @@ +"""Light platform for the Whirlpool Appliances integration.""" + +from typing import Any, override + +from whirlpool.oven import Cavity as OvenCavity, Oven + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .entity import WhirlpoolOvenEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the light platform.""" + appliances_manager = config_entry.runtime_data + async_add_entities( + WhirlpoolOvenLight(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + ) + + +class WhirlpoolOvenLight(WhirlpoolOvenEntity, LightEntity): + """Light for an oven cavity.""" + + _appliance: Oven + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven light.""" + super().__init__(appliance, cavity, "oven_light", "-light") + + @property + @override + def is_on(self) -> bool | None: + """Return whether the light is on.""" + return self._appliance.get_light(self.cavity) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + WhirlpoolOvenLight._check_service_request( + await self._appliance.set_light(True, self.cavity) + ) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + WhirlpoolOvenLight._check_service_request( + await self._appliance.set_light(False, self.cavity) + ) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 2a831caf8028..5c5581afa5fa 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -57,6 +57,17 @@ "name": "Upper oven stop" } }, + "light": { + "oven_light": { + "name": "Light" + }, + "oven_light_lower": { + "name": "Lower oven light" + }, + "oven_light_upper": { + "name": "Upper oven light" + } + }, "select": { "refrigerator_temperature_level": { "name": "Temperature level" diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index e2bd4a641db9..a713247d4419 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -182,6 +182,7 @@ def mock_oven_single_cavity_api(): mock_oven.get_oven_cavity_exists.side_effect = lambda cavity: ( cavity == oven.Cavity.Upper ) + mock_oven.get_light.return_value = True mock_oven.get_temp.return_value = 180 mock_oven.get_target_temp.return_value = 200 return mock_oven @@ -205,6 +206,7 @@ def mock_oven_dual_cavity_api(): oven.Cavity.Lower, ) ) + mock_oven.get_light.side_effect = lambda cavity: cavity == oven.Cavity.Upper mock_oven.get_temp.return_value = 180 mock_oven.get_target_temp.return_value = 200 return mock_oven diff --git a/tests/components/whirlpool/snapshots/test_light.ambr b/tests/components/whirlpool/snapshots/test_light.ambr new file mode 100644 index 000000000000..a7699bc54db5 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_light.ambr @@ -0,0 +1,178 @@ +# serializer version: 1 +# name: test_all_entities[light.dual_cavity_oven_lower_oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dual_cavity_oven_lower_oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower oven light', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_light_lower', + 'unique_id': 'said_oven_dual-light_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.dual_cavity_oven_lower_oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'Dual cavity oven Lower oven light', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.dual_cavity_oven_lower_oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[light.dual_cavity_oven_upper_oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dual_cavity_oven_upper_oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper oven light', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_light_upper', + 'unique_id': 'said_oven_dual-light_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.dual_cavity_oven_upper_oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'Dual cavity oven Upper oven light', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.dual_cavity_oven_upper_oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[light.single_cavity_oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.single_cavity_oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_light', + 'unique_id': 'said_oven_single-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.single_cavity_oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'Single cavity oven Light', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.single_cavity_oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/whirlpool/test_light.py b/tests/components/whirlpool/test_light.py new file mode 100644 index 000000000000..78951963cc6c --- /dev/null +++ b/tests/components/whirlpool/test_light.py @@ -0,0 +1,99 @@ +"""Test the Whirlpool light platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import whirlpool + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities + + +@pytest.fixture( + params=[ + ( + "light.single_cavity_oven_light", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "light.dual_cavity_oven_upper_oven_light", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "light.dual_cavity_oven_lower_oven_light", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_light_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven light entities.""" + return request.param + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.LIGHT) + + +@pytest.mark.parametrize( + ("service", "expected_state"), + [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)], +) +async def test_turn_on_off( + hass: HomeAssistant, + oven_light_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + service: str, + expected_state: bool, +) -> None: + """Test turning the oven light on and off.""" + entity_id, mock_fixture, cavity = oven_light_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock.set_light.assert_called_once_with(expected_state, cavity) + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +async def test_turn_on_off_failure( + hass: HomeAssistant, + oven_light_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + service: str, +) -> None: + """Test a failed light request raises HomeAssistantError.""" + entity_id, mock_fixture, _ = oven_light_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_light.return_value = False + await init_integration(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From 3dd3d85ecd9646800681657877fa3766a491a1fa Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 13 Jul 2026 11:23:46 -0700 Subject: [PATCH 568/707] Filter Roborock A01 query protocols by supported schema (#176421) --- .../components/roborock/coordinator.py | 65 +++++++++++-------- .../components/roborock/test_binary_sensor.py | 46 +++++++++++++ tests/components/roborock/test_select.py | 46 +++++++++++++ 3 files changed, 131 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 3a4af80c2df2..f6e30aa93306 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -490,6 +490,26 @@ class RoborockDataUpdateCoordinatorA01[ return self._device +ZEO_REQUEST_PROTOCOLS = [ + RoborockZeoProtocol.STATE, + RoborockZeoProtocol.COUNTDOWN, + RoborockZeoProtocol.WASHING_LEFT, + RoborockZeoProtocol.ERROR, + RoborockZeoProtocol.TIMES_AFTER_CLEAN, + RoborockZeoProtocol.DETERGENT_EMPTY, + RoborockZeoProtocol.SOFTENER_EMPTY, + RoborockZeoProtocol.DETERGENT_TYPE, + RoborockZeoProtocol.SOFTENER_TYPE, + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.SOUND_SET, +] + + class RoborockWashingMachineUpdateCoordinator( RoborockDataUpdateCoordinatorA01[RoborockZeoProtocol] ): @@ -505,25 +525,11 @@ class RoborockWashingMachineUpdateCoordinator( """Initialize.""" super().__init__(hass, config_entry, device) self.api = api - self.request_protocols: list[RoborockZeoProtocol] = [] - # This currently only supports the washing machine protocols + supported_schema_ids = device.product.supported_schema_ids self.request_protocols = [ - RoborockZeoProtocol.STATE, - RoborockZeoProtocol.COUNTDOWN, - RoborockZeoProtocol.WASHING_LEFT, - RoborockZeoProtocol.ERROR, - RoborockZeoProtocol.TIMES_AFTER_CLEAN, - RoborockZeoProtocol.DETERGENT_EMPTY, - RoborockZeoProtocol.SOFTENER_EMPTY, - RoborockZeoProtocol.DETERGENT_TYPE, - RoborockZeoProtocol.SOFTENER_TYPE, - RoborockZeoProtocol.MODE, - RoborockZeoProtocol.PROGRAM, - RoborockZeoProtocol.TEMP, - RoborockZeoProtocol.RINSE_TIMES, - RoborockZeoProtocol.SPIN_LEVEL, - RoborockZeoProtocol.DRYING_MODE, - RoborockZeoProtocol.SOUND_SET, + protocol + for protocol in ZEO_REQUEST_PROTOCOLS + if not supported_schema_ids or protocol in supported_schema_ids ] @override @@ -540,6 +546,16 @@ class RoborockWashingMachineUpdateCoordinator( ) from ex +DYAD_REQUEST_PROTOCOLS = [ + RoborockDyadDataProtocol.STATUS, + RoborockDyadDataProtocol.POWER, + RoborockDyadDataProtocol.MESH_LEFT, + RoborockDyadDataProtocol.BRUSH_LEFT, + RoborockDyadDataProtocol.ERROR, + RoborockDyadDataProtocol.TOTAL_RUN_TIME, +] + + class RoborockWetDryVacUpdateCoordinator( RoborockDataUpdateCoordinatorA01[RoborockDyadDataProtocol] ): @@ -555,14 +571,11 @@ class RoborockWetDryVacUpdateCoordinator( """Initialize.""" super().__init__(hass, config_entry, device) self.api = api - # This currenltly only supports the WetDryVac protocols - self.request_protocols: list[RoborockDyadDataProtocol] = [ - RoborockDyadDataProtocol.STATUS, - RoborockDyadDataProtocol.POWER, - RoborockDyadDataProtocol.MESH_LEFT, - RoborockDyadDataProtocol.BRUSH_LEFT, - RoborockDyadDataProtocol.ERROR, - RoborockDyadDataProtocol.TOTAL_RUN_TIME, + supported_schema_ids = device.product.supported_schema_ids + self.request_protocols = [ + protocol + for protocol in DYAD_REQUEST_PROTOCOLS + if not supported_schema_ids or protocol in supported_schema_ids ] @override diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index ab5e7d44bcc3..e2e5f0c28f45 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test Roborock Binary Sensor.""" +import copy from typing import Any import pytest @@ -76,3 +77,48 @@ async def test_binary_sensors_coordinator_state( state = hass.states.get("binary_sensor.zeo_one_detergent") assert state is not None assert state.state == expected_state + + +@pytest.mark.parametrize("platforms", [[Platform.BINARY_SENSOR]]) +async def test_zeo_request_protocols_filtered_by_schema( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test that Zeo request protocols are filtered by the device's supported schema IDs, ensuring correct entities are created.""" + # Find the first Zeo device + zeo_device_1 = next( + (device for device in fake_devices if device.zeo is not None), + None, + ) + assert zeo_device_1 is not None + + # Create a second Zeo device without softener in its schema + zeo_device_2 = copy.deepcopy(zeo_device_1) + zeo_device_2.device_info.duid = "zeo_duid_2" + zeo_device_2._duid = "zeo_duid_2" + zeo_device_2.device_info.name = "Zeo Two" + zeo_device_2._name = "Zeo Two" + zeo_device_2.device_info.sn = "zeo_sn_2" + + # Exclude softener parameters: 214 (SOFTENER_TYPE) and 227 (SOFTENER_EMPTY) + zeo_device_2.product.schema = [ + schema + for schema in zeo_device_2.product.schema + if schema.id not in ("214", "227") + ] + + # Add the second device to the list of fake devices + fake_devices.append(zeo_device_2) + + # Now set up the integration + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the first Zeo device has both detergent and softener entities + assert hass.states.get("binary_sensor.zeo_one_detergent") is not None + assert hass.states.get("binary_sensor.zeo_one_softener") is not None + + # Verify that the second Zeo device has detergent entities but NOT softener entities + assert hass.states.get("binary_sensor.zeo_two_detergent") is not None + assert hass.states.get("binary_sensor.zeo_two_softener") is None diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index bb3b15c84f43..aa02c334e9cb 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -1,5 +1,6 @@ """Test Roborock Select platform.""" +import copy from typing import Any from unittest.mock import AsyncMock, Mock, call @@ -538,3 +539,48 @@ async def test_q10_cleaning_mode_select_invalid_option( assert fake_q10_vacuum.b01_q10_properties fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_not_called() + + +@pytest.mark.parametrize("platforms", [[Platform.SELECT]]) +async def test_zeo_request_protocols_filtered_by_schema( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test that Zeo request protocols are filtered by the device's supported schema IDs, ensuring correct entities are created.""" + # Find the first Zeo device + zeo_device_1 = next( + (device for device in fake_devices if device.zeo is not None), + None, + ) + assert zeo_device_1 is not None + + # Create a second Zeo device without softener in its schema + zeo_device_2 = copy.deepcopy(zeo_device_1) + zeo_device_2.device_info.duid = "zeo_duid_2" + zeo_device_2._duid = "zeo_duid_2" + zeo_device_2.device_info.name = "Zeo Two" + zeo_device_2._name = "Zeo Two" + zeo_device_2.device_info.sn = "zeo_sn_2" + + # Exclude softener parameters: 214 (SOFTENER_TYPE) and 227 (SOFTENER_EMPTY) + zeo_device_2.product.schema = [ + schema + for schema in zeo_device_2.product.schema + if schema.id not in ("214", "227") + ] + + # Add the second device to the list of fake devices + fake_devices.append(zeo_device_2) + + # Now set up the integration + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the first Zeo device has both detergent and softener entities + assert hass.states.get("select.zeo_one_detergent_type") is not None + assert hass.states.get("select.zeo_one_softener_type") is not None + + # Verify that the second Zeo device has detergent entities but NOT softener entities + assert hass.states.get("select.zeo_two_detergent_type") is not None + assert hass.states.get("select.zeo_two_softener_type") is None From 3b12041c7a38647028a6e43c2481837fe7615ad9 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 13 Jul 2026 20:24:47 +0200 Subject: [PATCH 569/707] Use mimalloc as the Python allocator instead of preloading jemalloc (#175604) Co-authored-by: Claude Opus 4.8 (1M context) --- rootfs/etc/services.d/home-assistant/run | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/rootfs/etc/services.d/home-assistant/run b/rootfs/etc/services.d/home-assistant/run index 40ec07c15431..950c6a6250e3 100755 --- a/rootfs/etc/services.d/home-assistant/run +++ b/rootfs/etc/services.d/home-assistant/run @@ -5,9 +5,14 @@ cd /config || bashio::exit.nok "Can't find config folder!" -# Enable mimalloc for Home Assistant Core, unless disabled -if [[ -z "${DISABLE_JEMALLOC+x}" ]]; then - export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" - export MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" +# Use mimalloc as Python's object allocator by default. It is bundled in CPython +# (3.13+), so no LD_PRELOAD or extra library is required, and it uses noticeably +# less memory than the previously preloaded jemalloc. Override or disable via +# PYTHONMALLOC, e.g. `PYTHONMALLOC=pymalloc` for the default allocator. +export PYTHONMALLOC="${PYTHONMALLOC:-mimalloc}" + +if [[ -n "${DISABLE_JEMALLOC+x}" ]]; then + bashio::log.warning "DISABLE_JEMALLOC is set but no longer has any effect: jemalloc has been replaced by mimalloc. Set PYTHONMALLOC=pymalloc to use the default allocator instead." fi + exec python3 -m homeassistant --config /config From 4c2b2328ced72aa42c1ad2be91438ffda36a6d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 13 Jul 2026 20:14:33 +0100 Subject: [PATCH 570/707] Exclude HTML comments from copilot PR template completeness check (#176433) --- .github/copilot-instructions.md | 2 +- script/gen_copilot_instructions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 771dd3d070fc..5fa2da4a257b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,7 +8,7 @@ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. -- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. +- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments. Even unchecked checkboxes or empty sections must be present. This is a hard requirement. ## Pull Request template diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index ae25f35779a6..5f36d3ef2ed0 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -26,7 +26,7 @@ COPILOT_SPECIFIC_INSTRUCTIONS = """ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. -- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. Nothing from the template should be missing. Even unchecked checkboxes or empty sections must be present. This is an hard requirement. +- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments. Even unchecked checkboxes or empty sections must be present. This is a hard requirement. ## Pull Request template From e42bb890d638d949438a546b8e541a17a61ef235 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 14 Jul 2026 00:05:25 +0200 Subject: [PATCH 571/707] Add LED Infrared integration (#175294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/led_infrared/__init__.py | 18 ++ .../components/led_infrared/config_flow.py | 94 ++++++ .../components/led_infrared/const.py | 14 + .../components/led_infrared/icons.json | 42 +++ .../components/led_infrared/light.py | 129 ++++++++ .../components/led_infrared/manifest.json | 11 + .../led_infrared/quality_scale.yaml | 120 ++++++++ .../components/led_infrared/strings.json | 73 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + tests/components/led_infrared/__init__.py | 1 + tests/components/led_infrared/conftest.py | 60 ++++ .../led_infrared/snapshots/test_light.ambr | 106 +++++++ .../led_infrared/test_config_flow.py | 99 +++++++ tests/components/led_infrared/test_init.py | 22 ++ tests/components/led_infrared/test_light.py | 280 ++++++++++++++++++ 19 files changed, 1089 insertions(+) create mode 100644 homeassistant/components/led_infrared/__init__.py create mode 100644 homeassistant/components/led_infrared/config_flow.py create mode 100644 homeassistant/components/led_infrared/const.py create mode 100644 homeassistant/components/led_infrared/icons.json create mode 100644 homeassistant/components/led_infrared/light.py create mode 100644 homeassistant/components/led_infrared/manifest.json create mode 100644 homeassistant/components/led_infrared/quality_scale.yaml create mode 100644 homeassistant/components/led_infrared/strings.json create mode 100644 tests/components/led_infrared/__init__.py create mode 100644 tests/components/led_infrared/conftest.py create mode 100644 tests/components/led_infrared/snapshots/test_light.ambr create mode 100644 tests/components/led_infrared/test_config_flow.py create mode 100644 tests/components/led_infrared/test_init.py create mode 100644 tests/components/led_infrared/test_light.py diff --git a/.strict-typing b/.strict-typing index f878a42c6b54..e3629e702389 100644 --- a/.strict-typing +++ b/.strict-typing @@ -337,6 +337,7 @@ homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* +homeassistant.components.led_infrared.* homeassistant.components.lektrico.* homeassistant.components.letpot.* homeassistant.components.lg_infrared.* diff --git a/CODEOWNERS b/CODEOWNERS index b5bcbe335b65..8d02a119003d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1001,6 +1001,8 @@ CLAUDE.md @home-assistant/core /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco +/homeassistant/components/led_infrared/ @tr4nt0r +/tests/components/led_infrared/ @tr4nt0r /homeassistant/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico /homeassistant/components/letpot/ @jpelgrom diff --git a/homeassistant/components/led_infrared/__init__.py b/homeassistant/components/led_infrared/__init__.py new file mode 100644 index 000000000000..12d4096ea426 --- /dev/null +++ b/homeassistant/components/led_infrared/__init__.py @@ -0,0 +1,18 @@ +"""The LED Infrared integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up LED Infrared from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py new file mode 100644 index 000000000000..d15c734d4ea8 --- /dev/null +++ b/homeassistant/components/led_infrared/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for the LED Infrared integration.""" + +from typing import TYPE_CHECKING, Any, override + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +DEVICE_NAMES = { + LEDIrDeviceType.GENERIC_24_KEY: "24-key remote", + LEDIrDeviceType.GENERIC_13_KEY: "13-key remote", +} + + +class LEDIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LED Infrared.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: user_input[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + + title_entity_id = emitter_id + if TYPE_CHECKING: + assert title_entity_id is not None + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(title_entity_id) + title_entity_name = ( + entry.name or entry.original_name or title_entity_id + if entry + else title_entity_id + ) + return self.async_create_entry( + title=f"LED light with {DEVICE_NAMES[LEDIrDeviceType(user_input[CONF_DEVICE_TYPE])]} via {title_entity_name}", + data=user_input, + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_TYPE): SelectSelector( + SelectSelectorConfig( + options=[ + device_type.value for device_type in LEDIrDeviceType + ], + translation_key=CONF_DEVICE_TYPE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + errors=errors, + description_placeholders={ + "docs_url": "https://www.home-assistant.io/integrations/led_infrared" + }, + ) diff --git a/homeassistant/components/led_infrared/const.py b/homeassistant/components/led_infrared/const.py new file mode 100644 index 000000000000..7c5295f2b586 --- /dev/null +++ b/homeassistant/components/led_infrared/const.py @@ -0,0 +1,14 @@ +"""Constants for the LED Infrared integration.""" + +from enum import StrEnum + +DOMAIN = "led_infrared" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_DEVICE_TYPE = "device_type" + + +class LEDIrDeviceType(StrEnum): + """LED Infrared device types.""" + + GENERIC_24_KEY = "generic_24_key" + GENERIC_13_KEY = "generic_13_key" diff --git a/homeassistant/components/led_infrared/icons.json b/homeassistant/components/led_infrared/icons.json new file mode 100644 index 000000000000..d1e1784e95ce --- /dev/null +++ b/homeassistant/components/led_infrared/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "mdi:palette", + "cyan": "mdi:palette", + "dark_cyan": "mdi:palette", + "fade": "mdi:gradient-horizontal", + "flash": "mdi:flash", + "green": "mdi:palette", + "light_green": "mdi:palette", + "mode_1": "mdi:numeric-1-box", + "mode_2": "mdi:numeric-2-box", + "mode_3": "mdi:numeric-3-box", + "mode_4": "mdi:numeric-4-box", + "mode_5": "mdi:numeric-5-box", + "mode_6": "mdi:numeric-6-box", + "mode_7": "mdi:numeric-7-box", + "mode_8": "mdi:numeric-8-box", + "orange": "mdi:palette", + "orange_red": "mdi:palette", + "plum": "mdi:palette", + "purple": "mdi:palette", + "rebecca_purple": "mdi:palette", + "red": "mdi:palette", + "sky_blue": "mdi:palette", + "smooth": "mdi:looks", + "strobe": "mdi:light-flood-down", + "tomato": "mdi:palette", + "turquoise": "mdi:palette", + "white": "mdi:palette", + "yellow": "mdi:palette" + } + } + } + } + } + } +} diff --git a/homeassistant/components/led_infrared/light.py b/homeassistant/components/led_infrared/light.py new file mode 100644 index 000000000000..7f0d385218a1 --- /dev/null +++ b/homeassistant/components/led_infrared/light.py @@ -0,0 +1,129 @@ +"""Light platform for LED Infrared integration.""" + +from typing import Any, override + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.light import ( + ATTR_EFFECT, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +PARALLEL_UPDATES = 1 + +CODES = { + LEDIrDeviceType.GENERIC_24_KEY: Generic24KeyCode, + LEDIrDeviceType.GENERIC_13_KEY: Generic13KeyCode, +} + + +SUPPORTED_EFFECTS = { + LEDIrDeviceType.GENERIC_24_KEY: ["flash", "strobe", "fade", "smooth"], + LEDIrDeviceType.GENERIC_13_KEY: [ + "mode_1", + "mode_2", + "mode_3", + "mode_4", + "mode_5", + "mode_6", + "mode_7", + "mode_8", + ], +} + + +SUPPORTED_COLORS = { + LEDIrDeviceType.GENERIC_24_KEY: [ + "red", + "green", + "blue", + "white", + "tomato", + "light_green", + "sky_blue", + "orange_red", + "cyan", + "rebecca_purple", + "orange", + "turquoise", + "purple", + "yellow", + "dark_cyan", + "plum", + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up platform from config entry.""" + if not (infrared_entity_id := entry.data.get(CONF_INFRARED_ENTITY_ID)): + return + + async_add_entities( + [LEDIrLightEntity(entry, entry.data[CONF_DEVICE_TYPE], infrared_entity_id)] + ) + + +class LEDIrLightEntity(InfraredEmitterConsumerEntity, LightEntity): + """Represents a LED Infrared light entity.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_effect_list: list[str] + _attr_has_entity_name = True + _attr_name = None + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "light" + + def __init__( + self, + entry: ConfigEntry, + device_type: LEDIrDeviceType, + infrared_entity_id: str, + ) -> None: + """Initialize the entity.""" + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + ) + + self._infrared_emitter_entity_id = infrared_entity_id + + self._codes = CODES[device_type] + self._attr_effect_list = SUPPORTED_EFFECTS.get( + device_type, [] + ) + SUPPORTED_COLORS.get(device_type, []) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + await self._send_command(self._codes.ON.to_command()) + self._attr_is_on = True + effect: str | None = kwargs.get(ATTR_EFFECT) + if effect and effect in self._attr_effect_list: + await self._send_command(self._codes[effect.upper()].to_command()) + self._attr_effect = effect + + self.async_write_ha_state() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._send_command(self._codes.OFF.to_command()) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/led_infrared/manifest.json b/homeassistant/components/led_infrared/manifest.json new file mode 100644 index 000000000000..501f79a54b73 --- /dev/null +++ b/homeassistant/components/led_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "led_infrared", + "name": "LED Infrared", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/led_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml new file mode 100644 index 000000000000..b3909f093af2 --- /dev/null +++ b/homeassistant/components/led_infrared/quality_scale.yaml @@ -0,0 +1,120 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: exempt + comment: This integration has only one platform + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during config flow. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration has no repairs. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not do HTTP requests. + strict-typing: done diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json new file mode 100644 index 000000000000..5b13ae6caefa --- /dev/null +++ b/homeassistant/components/led_infrared/strings.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured with this infrared entity.", + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]" + }, + "error": { + "missing_infrared_entity": "Select an infrared emitter." + }, + "step": { + "user": { + "data": { + "device_type": "[%key:common::generic::device_type%]", + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "device_type": "The type of remote control used for the LED light bulb, lamp, or controller.", + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "description": "Select the device type and an infrared emitter. You can identify the correct device based on the remote control used. Please refer to the [documentation]({docs_url}).", + "title": "Set up LED Infrared device" + } + } + }, + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "Color: Blue", + "cyan": "Color: Cyan", + "dark_cyan": "Color: Dark cyan", + "fade": "Fade", + "flash": "Flash", + "green": "Color: Green", + "light_green": "Color: Light green", + "mode_1": "Mode 1", + "mode_2": "Mode 2", + "mode_3": "Mode 3", + "mode_4": "Mode 4", + "mode_5": "Mode 5", + "mode_6": "Mode 6", + "mode_7": "Mode 7", + "mode_8": "Mode 8", + "orange": "Color: Orange", + "orange_red": "Color: Orange red", + "plum": "Color: Plum", + "purple": "Color: Purple", + "rebecca_purple": "Color: Rebecca purple", + "red": "Color: Red", + "sky_blue": "Color: Sky blue", + "smooth": "Smooth", + "strobe": "Strobe", + "tomato": "Color: Tomato", + "turquoise": "Color: Turquoise", + "white": "Color: White", + "yellow": "Color: Yellow" + } + } + } + } + } + }, + "selector": { + "device_type": { + "options": { + "generic_13_key": "13-key remote control", + "generic_24_key": "24-key remote control" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 098e872eb0a6..83f559cf2811 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -413,6 +413,7 @@ FLOWS = { "ld2410_ble", "leaone", "led_ble", + "led_infrared", "lektrico", "letpot", "lg_infrared", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 92c50b56e104..2387a9de906f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3722,6 +3722,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "led_infrared": { + "name": "LED Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "legrand": { "name": "Legrand", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 70d09d0ae3f4..73645a4a2360 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3127,6 +3127,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.led_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lektrico.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/led_infrared/__init__.py b/tests/components/led_infrared/__init__.py new file mode 100644 index 000000000000..1d9171961d86 --- /dev/null +++ b/tests/components/led_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the LED Infrared integration.""" diff --git a/tests/components/led_infrared/conftest.py b/tests/components/led_infrared/conftest.py new file mode 100644 index 000000000000..b608b97a0007 --- /dev/null +++ b/tests/components/led_infrared/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the LED Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.led_infrared.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + + +@pytest.fixture(name="infrared_codes") +def mock_infrared_code_to_command() -> Generator[None]: + """Patch to_command to return the code directly. + + This allows tests to assert on the high-level code enum value + rather than the raw NEC timings. + """ + with ( + patch( + "infrared_protocols.codes.generic.led.Generic24KeyCode.to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ) as mock_to_command, + patch( + "infrared_protocols.codes.generic.led.Generic13KeyCode.to_command", + new=mock_to_command, + ), + ): + yield diff --git a/tests/components/led_infrared/snapshots/test_light.ambr b/tests/components/led_infrared/snapshots/test_light.ambr new file mode 100644 index 000000000000..ae797297dfa0 --- /dev/null +++ b/tests/components/led_infrared/snapshots/test_light.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[light.led_infrared_via_test_ir_emitter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': '1234567890', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.led_infrared_via_test_ir_emitter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : None, + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : 'LED Infrared via Test IR emitter', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/led_infrared/test_config_flow.py b/tests/components/led_infrared/test_config_flow.py new file mode 100644 index 000000000000..760b1c2b9c38 --- /dev/null +++ b/tests/components/led_infrared/test_config_flow.py @@ -0,0 +1,99 @@ +"""Test the LED Infrared config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "LED light with 24-key remote via Test IR emitter" + assert result["data"] == { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test we abort when already configured.""" + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_requires_emitter( + hass: HomeAssistant, +) -> None: + """Test user flow requires an infrared emitter.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" diff --git a/tests/components/led_infrared/test_init.py b/tests/components/led_infrared/test_init.py new file mode 100644 index 000000000000..4e8cf794c27a --- /dev/null +++ b/tests/components/led_infrared/test_init.py @@ -0,0 +1,22 @@ +"""Tests for the LED Infrared integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/led_infrared/test_light.py b/tests/components/led_infrared/test_light.py new file mode 100644 index 000000000000..1e2083265ac5 --- /dev/null +++ b/tests/components/led_infrared/test_light.py @@ -0,0 +1,280 @@ +"""Tests for the LED Infrared light platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.fixture(autouse=True) +def light_only() -> Generator[None]: + """Enable only the light platform.""" + with patch( + "homeassistant.components.led_infrared.PLATFORMS", + [Platform.LIGHT], + ): + yield + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of light platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device_type", "service", "service_data", "expected_codes"), + [ + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {}, + [Generic24KeyCode.ON], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "flash"}, + [Generic24KeyCode.ON, Generic24KeyCode.FLASH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "strobe"}, + [Generic24KeyCode.ON, Generic24KeyCode.STROBE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "fade"}, + [Generic24KeyCode.ON, Generic24KeyCode.FADE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "smooth"}, + [Generic24KeyCode.ON, Generic24KeyCode.SMOOTH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "red"}, + [Generic24KeyCode.ON, Generic24KeyCode.RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "green"}, + [Generic24KeyCode.ON, Generic24KeyCode.GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "white"}, + [Generic24KeyCode.ON, Generic24KeyCode.WHITE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange_red"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE_RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "tomato"}, + [Generic24KeyCode.ON, Generic24KeyCode.TOMATO], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "light_green"}, + [Generic24KeyCode.ON, Generic24KeyCode.LIGHT_GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "sky_blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.SKY_BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "rebecca_purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.REBECCA_PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "turquoise"}, + [Generic24KeyCode.ON, Generic24KeyCode.TURQUOISE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "yellow"}, + [Generic24KeyCode.ON, Generic24KeyCode.YELLOW], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "dark_cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.DARK_CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "plum"}, + [Generic24KeyCode.ON, Generic24KeyCode.PLUM], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_OFF, + {}, + [Generic24KeyCode.OFF], + ), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_ON, {}, [Generic13KeyCode.ON]), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_OFF, {}, [Generic13KeyCode.OFF]), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_1"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_1], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_2"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_2], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_3"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_3], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_4"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_4], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_5"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_5], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_6"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_6], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_7"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_7], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_8"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_8], + ), + ], +) +@pytest.mark.usefixtures("infrared_codes") +async def test_light_actions( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + device_type: LEDIrDeviceType, + service: str, + service_data: dict[str, str], + expected_codes: list[Generic24KeyCode | Generic13KeyCode], +) -> None: + """Test light actions.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: device_type, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.led_infrared_via_test_ir_emitter", **service_data}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == len(expected_codes) + assert mock_infrared_emitter_entity.send_command_calls == expected_codes From 079ab97702b839f3f86a597d1cbe6d34f87c0138 Mon Sep 17 00:00:00 2001 From: Filipe Date: Tue, 14 Jul 2026 10:15:15 +1200 Subject: [PATCH 572/707] Add S3000 Pro support to Edifier Infrared (#175425) --- .../components/edifier_infrared/button.py | 43 +++++++++++++++++++ .../components/edifier_infrared/const.py | 2 + .../edifier_infrared/media_player.py | 14 ++++++ .../components/edifier_infrared/strings.json | 21 +++++++++ tests/components/edifier_infrared/conftest.py | 5 +++ .../edifier_infrared/test_config_flow.py | 1 + 6 files changed, 86 insertions(+) diff --git a/homeassistant/components/edifier_infrared/button.py b/homeassistant/components/edifier_infrared/button.py index 8240e357cdcb..10cd8d2e481e 100644 --- a/homeassistant/components/edifier_infrared/button.py +++ b/homeassistant/components/edifier_infrared/button.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.infrared import InfraredEmitterConsumerEntity @@ -141,6 +142,48 @@ COMMAND_SET_BUTTONS: dict[ command_code=EdifierRC20GCode.COAX, ), ), + EdifierCommandSet.S3000PRO: ( + EdifierIrButtonEntityDescription( + key="usb", + translation_key="usb", + command_code=EdifierS3000ProCode.USB, + ), + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierS3000ProCode.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line_bal", + translation_key="line_bal", + command_code=EdifierS3000ProCode.LINE_BAL, + ), + EdifierIrButtonEntityDescription( + key="opt_coax", + translation_key="opt_coax", + command_code=EdifierS3000ProCode.OPT_COAX, + ), + EdifierIrButtonEntityDescription( + key="eq_monitor", + translation_key="eq_monitor", + command_code=EdifierS3000ProCode.EQ_MONITOR, + ), + EdifierIrButtonEntityDescription( + key="eq_dynamic", + translation_key="eq_dynamic", + command_code=EdifierS3000ProCode.EQ_DYNAMIC, + ), + EdifierIrButtonEntityDescription( + key="eq_classic", + translation_key="eq_classic", + command_code=EdifierS3000ProCode.EQ_CLASSIC, + ), + EdifierIrButtonEntityDescription( + key="eq_vocal", + translation_key="eq_vocal", + command_code=EdifierS3000ProCode.EQ_VOCAL, + ), + ), } diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py index 057f71a7c510..4fd4b959d7b2 100644 --- a/homeassistant/components/edifier_infrared/const.py +++ b/homeassistant/components/edifier_infrared/const.py @@ -5,6 +5,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode DOMAIN = "edifier_infrared" CONF_INFRARED_ENTITY_ID = "infrared_entity_id" @@ -16,4 +17,5 @@ type EdifierCode = ( | EdifierR1280TCode | EdifierS360DBCode | EdifierRC20GCode + | EdifierS3000ProCode ) diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py index 6944e05b9156..8c9963e80e9d 100644 --- a/homeassistant/components/edifier_infrared/media_player.py +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.components.media_player import ( @@ -92,6 +93,19 @@ COMMAND_SET_COMMANDS: dict[ MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,), MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,), }, + EdifierCommandSet.S3000PRO: { + MediaPlayerEntityFeature.TURN_ON: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierS3000ProCode.VOLUME_UP,), + (EdifierS3000ProCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierS3000ProCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS3000ProCode.NEXT,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS3000ProCode.PREVIOUS,), + }, } diff --git a/homeassistant/components/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json index 28235e17699b..0ed3c9e2c2a8 100644 --- a/homeassistant/components/edifier_infrared/strings.json +++ b/homeassistant/components/edifier_infrared/strings.json @@ -30,6 +30,18 @@ "coax": { "name": "Coaxial" }, + "eq_classic": { + "name": "Classic EQ" + }, + "eq_dynamic": { + "name": "Dynamic EQ" + }, + "eq_monitor": { + "name": "Monitor EQ" + }, + "eq_vocal": { + "name": "Vocal EQ" + }, "fx_off": { "name": "FX off" }, @@ -42,11 +54,20 @@ "line_2": { "name": "Line 2" }, + "line_bal": { + "name": "Line / Balanced" + }, + "opt_coax": { + "name": "Optical / Coaxial" + }, "optical": { "name": "Optical" }, "pc": { "name": "PC" + }, + "usb": { + "name": "USB" } } } diff --git a/tests/components/edifier_infrared/conftest.py b/tests/components/edifier_infrared/conftest.py index 25eec8b6654a..7cc22ec7b2a2 100644 --- a/tests/components/edifier_infrared/conftest.py +++ b/tests/components/edifier_infrared/conftest.py @@ -77,6 +77,11 @@ def mock_edifier_code_to_command() -> Generator[None]: autospec=True, side_effect=lambda self: self, ), + patch( + "infrared_protocols.codes.edifier.s3000pro.EdifierS3000ProCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), ): yield diff --git a/tests/components/edifier_infrared/test_config_flow.py b/tests/components/edifier_infrared/test_config_flow.py index f12b3b33899e..119cfa8ad0c1 100644 --- a/tests/components/edifier_infrared/test_config_flow.py +++ b/tests/components/edifier_infrared/test_config_flow.py @@ -26,6 +26,7 @@ from tests.components.infrared import EMITTER_ENTITY_ID (EdifierModel.R1280T, EdifierCommandSet.R1280T), (EdifierModel.S360DB, EdifierCommandSet.S360DB), (EdifierModel.RC20G, EdifierCommandSet.RC20G), + (EdifierModel.S3000PRO, EdifierCommandSet.S3000PRO), ], ) @pytest.mark.usefixtures("mock_infrared_emitter_entity") From 2262942c98f69cab6b7186430db1a282deb50f78 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:50:30 +0200 Subject: [PATCH 573/707] Use ClimateEntityStateAttribute enum in Teslemetry climate (#176379) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/teslemetry/climate.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index a4268319b3bc..a06b9a9610bb 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( HVAC_MODES, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACMode, ) from homeassistant.const import ( @@ -287,9 +288,15 @@ class TeslemetryStreamingClimateEntity( self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("current_temperature") - self._attr_target_temperature = state.attributes.get("temperature") - self._attr_preset_mode = state.attributes.get("preset_mode") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) + self._attr_preset_mode = state.attributes.get( + ClimateEntityStateAttribute.PRESET_MODE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( @@ -531,8 +538,12 @@ class TeslemetryStreamingCabinOverheatProtectionEntity( self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("current_temperature") - self._attr_target_temperature = state.attributes.get("temperature") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( From 9fba0932f4b55f4663551f0ef3d38d28aa2ed1ad Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 14 Jul 2026 08:25:34 +0200 Subject: [PATCH 574/707] Add reconfigure flow to LED Infrared integration (#176461) --- .../components/led_infrared/config_flow.py | 45 +++++++++ .../led_infrared/quality_scale.yaml | 2 +- .../components/led_infrared/strings.json | 12 ++- .../led_infrared/test_config_flow.py | 94 +++++++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py index d15c734d4ea8..dc5998297723 100644 --- a/homeassistant/components/led_infrared/config_flow.py +++ b/homeassistant/components/led_infrared/config_flow.py @@ -92,3 +92,48 @@ class LEDIrConfigFlow(ConfigFlow, domain=DOMAIN): "docs_url": "https://www.home-assistant.io/integrations/led_infrared" }, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ) + } + ), + entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml index b3909f093af2..a1fe453f6102 100644 --- a/homeassistant/components/led_infrared/quality_scale.yaml +++ b/homeassistant/components/led_infrared/quality_scale.yaml @@ -97,7 +97,7 @@ rules: comment: | This integration does not raise exceptions. icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json index 5b13ae6caefa..a7735543a8b6 100644 --- a/homeassistant/components/led_infrared/strings.json +++ b/homeassistant/components/led_infrared/strings.json @@ -2,12 +2,22 @@ "config": { "abort": { "already_configured": "This device has already been configured with this infrared entity.", - "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]" + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "missing_infrared_entity": "Select an infrared emitter." }, "step": { + "reconfigure": { + "data": { + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "title": "Reconfigure LED Infrared device" + }, "user": { "data": { "device_type": "[%key:common::generic::device_type%]", diff --git a/tests/components/led_infrared/test_config_flow.py b/tests/components/led_infrared/test_config_flow.py index 760b1c2b9c38..dcbdab4cd4ea 100644 --- a/tests/components/led_infrared/test_config_flow.py +++ b/tests/components/led_infrared/test_config_flow.py @@ -97,3 +97,97 @@ async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_infrared_entities" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure(hass: HomeAssistant) -> None: + """Test reconfigure flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_INFRARED_ENTITY_ID] == EMITTER_ENTITY_ID + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_reconfigure_flow_requires_emitter( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow requires an infrared emitter.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="0987654321", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + config_entry_2.add_to_hass(hass) + result = await config_entry_2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_reconfigure_flow_no_emitters( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow aborts when no infrared emitters exist.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" From 1a031ab6f85cd17e060250f803fa61d7e8ab9be3 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 14 Jul 2026 09:26:54 +0300 Subject: [PATCH 575/707] OpenAI GPT-5.6 support (#176450) --- .../openai_conversation/config_flow.py | 15 +++++++ .../components/openai_conversation/const.py | 2 + .../components/openai_conversation/entity.py | 9 +++- .../openai_conversation/strings.json | 5 +++ .../openai_conversation/conftest.py | 6 ++- .../snapshots/test_conversation.ambr | 21 +++++++++ .../openai_conversation/test_config_flow.py | 17 ++++--- .../openai_conversation/test_conversation.py | 44 +++++++++++++++++++ 8 files changed, 111 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c773d3399695..05ed4fe18f5f 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -48,6 +48,7 @@ from .const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -77,6 +78,7 @@ from .const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -421,6 +423,18 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) + if model.startswith("gpt-5.6"): + step_schema.update( + { + vol.Optional( + CONF_PRO_MODE, + default=RECOMMENDED_PRO_MODE, + ): bool, + } + ) + elif CONF_PRO_MODE in options: + options.pop(CONF_PRO_MODE) + if model.startswith("gpt-5"): step_schema.update( { @@ -592,6 +606,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): return [] models_reasoning_map: dict[str | tuple[str, ...], list[str]] = { + "gpt-5.6": ["none", "low", "medium", "high", "xhigh", "max"], ("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"], ("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [ "none", diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 5236a0d9f53a..6f76455b0c02 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -20,6 +20,7 @@ CONF_IMAGE_MODEL = "image_model" CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" +CONF_PRO_MODE = "pro_mode" CONF_REASONING_EFFORT = "reasoning_effort" CONF_REASONING_SUMMARY = "reasoning_summary" CONF_RECOMMENDED = "recommended" @@ -41,6 +42,7 @@ RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_IMAGE_MODEL = "gpt-image-2" RECOMMENDED_MAX_TOKENS = 3000 +RECOMMENDED_PRO_MODE = False RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_STORE_RESPONSES = False RECOMMENDED_REASONING_SUMMARY = "auto" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 5ac94beb19a5..5fa447e9b925 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -73,6 +73,7 @@ from .const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, @@ -93,6 +94,7 @@ from .const import ( RECOMMENDED_CHAT_MODEL, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -497,7 +499,7 @@ class OpenAIBaseLLMEntity(Entity): entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( + async def _async_handle_chat_log( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, @@ -528,11 +530,16 @@ class OpenAIBaseLLMEntity(Entity): if not model_args["model"].startswith("gpt-5-pro") else "high", # GPT-5 pro only supports reasoning.effort: high } + reasoning_summary = options.get( CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY ) if reasoning_summary != "off": reasoning["summary"] = reasoning_summary + + if options.get(CONF_PRO_MODE, RECOMMENDED_PRO_MODE): + reasoning["mode"] = "pro" + model_args["reasoning"] = reasoning model_args["include"] = ["reasoning.encrypted_content"] diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 03637baf4868..6b7d21ea44c2 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -71,6 +71,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", @@ -82,6 +83,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", @@ -138,6 +140,7 @@ "code_interpreter": "Enable code interpreter tool", "image_model": "Image generation model", "inline_citations": "Include links in web search results", + "pro_mode": "Pro mode", "reasoning_effort": "Reasoning effort", "reasoning_summary": "Reasoning summary", "search_context_size": "Search context size", @@ -149,6 +152,7 @@ "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", "image_model": "The model to use when generating images", "inline_citations": "If disabled, additional prompt is added to ask the model to not include source citations", + "pro_mode": "Perform more model work to improve reliability on difficult tasks and return a single final answer", "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model", "search_context_size": "High level guidance for the amount of context window space to use for the search", @@ -233,6 +237,7 @@ "options": { "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", + "max": "Max", "medium": "[%key:common::state::medium%]", "minimal": "Minimal", "none": "None", diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 2839fe10a0ca..22a18743394b 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -92,7 +92,7 @@ def mock_config_entry( @pytest.fixture -def mock_config_entry_with_assist( +async def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -101,11 +101,12 @@ def mock_config_entry_with_assist( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) + await hass.async_block_till_done() return mock_config_entry @pytest.fixture -def mock_config_entry_with_reasoning_model( +async def mock_config_entry_with_reasoning_model( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -114,6 +115,7 @@ def mock_config_entry_with_reasoning_model( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "gpt-5-mini"}, ) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index caf16e6990da..dac962c59c7f 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -297,6 +297,27 @@ }), ]) # --- +# name: test_model_args[subentry_options0] + dict({ + 'include': list([ + 'reasoning.encrypted_content', + ]), + 'max_output_tokens': 3000, + 'model': 'gpt-5.6-sol', + 'prompt_cache_retention': '24h', + 'reasoning': dict({ + 'effort': 'low', + 'mode': 'pro', + 'summary': 'auto', + }), + 'service_tier': 'auto', + 'store': False, + 'stream': True, + 'text': dict({ + 'verbosity': 'medium', + }), + }) +# --- # name: test_web_search[False] list([ dict({ diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index a3cb3999e916..d83c1f263d1e 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.components.openai_conversation.const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -273,6 +274,7 @@ async def test_subentry_unsupported_model( ("gpt-5.4-pro", ["medium", "high", "xhigh"]), ("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.5-pro", ["medium", "high", "xhigh"]), + ("gpt-5.6", ["none", "low", "medium", "high", "xhigh", "max"]), ], ) async def test_subentry_reasoning_effort_list( @@ -466,6 +468,8 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch( @pytest.mark.parametrize( ("model", "service_tier_options"), [ + ("gpt-5.6", ["auto", "flex", "default", "priority"]), + ("gpt-5.5", ["auto", "flex", "default", "priority"]), ("gpt-5.4", ["auto", "flex", "default", "priority"]), ("gpt-5.4-pro", ["auto", "flex", "default", "priority"]), ("gpt-5.2", ["auto", "flex", "default", "priority"]), @@ -817,12 +821,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, { - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -831,17 +835,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_STORE_RESPONSES: False, - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -850,6 +855,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), # Test that old options are removed after reconfiguration @@ -966,7 +972,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", @@ -974,6 +980,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_SERVICE_TIER: "flex", CONF_CODE_INTERPRETER: True, CONF_VERBOSITY: "medium", + CONF_PRO_MODE: True, }, ( { diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index d1e7ec528bd9..933e57bc7d1b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -21,6 +21,7 @@ from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_CODE_INTERPRETER, + CONF_PRO_MODE, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, CONF_STORE_RESPONSES, @@ -817,3 +818,46 @@ async def test_flex_tier_retry( ) assert mock_create_stream.mock_calls[0][2]["service_tier"] == "flex" assert mock_create_stream.mock_calls[1][2]["service_tier"] == "default" + + +@pytest.mark.parametrize( + "subentry_options", [{CONF_CHAT_MODEL: "gpt-5.6-sol", CONF_PRO_MODE: True}] +) +async def test_model_args( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, + subentry_options: dict, +) -> None: + """Test model arguments for various configuration.""" + + subentry = next( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data=subentry_options, + ) + await hass.async_block_till_done() + + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi!", output_index=0), + ] + + result = await conversation.async_converse( + hass, + "Hello", + None, + Context(), + agent_id="conversation.openai_conversation", + ) + + model_args = mock_create_stream.call_args.kwargs.copy() + model_args.pop("input") + assert model_args.pop("user") == result.conversation_id + assert model_args == snapshot From e51ee5cdcfbf29c452f13fe196a669a6c988cd34 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:02 +0200 Subject: [PATCH 576/707] Use EntityStateAttribute enum in Geofency (#176465) --- homeassistant/components/geofency/device_tracker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 788a5dffac79..8d7c3b24cc42 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -3,7 +3,7 @@ from typing import override from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -92,8 +92,8 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) @override async def async_will_remove_from_hass(self) -> None: From 40553198b9f5d69c81819a9f9de376bfcf1ffda2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:21 +0200 Subject: [PATCH 577/707] Use EntityStateAttribute enum in Proximity (#176469) --- homeassistant/components/proximity/diagnostics.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index c304b4822f37..a5e4d179bcec 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -7,12 +7,11 @@ from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.components.person import ATTR_USER_ID from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant @@ -21,8 +20,8 @@ from .coordinator import ProximityConfigEntry TO_REDACT = { ATTR_GPS, ATTR_IP, - ATTR_LATITUDE, - ATTR_LONGITUDE, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, ATTR_MAC, ATTR_USER_ID, "context", From 710b3be2c1788d7de270cd0d73b39ecb66587f90 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:33 +0200 Subject: [PATCH 578/707] Use EntityStateAttribute enum in Prometheus (#176468) --- homeassistant/components/prometheus/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index d1ba2dede5d7..9ed5cfcea4de 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -39,8 +39,6 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONTENT_TYPE_TEXT_PLAIN, EVENT_STATE_CHANGED, PERCENTAGE, @@ -770,14 +768,18 @@ class PrometheusMetrics: "Distance of the geo location event from home in meters", labels, ).set(value) - if (latitude := state.attributes.get(ATTR_LATITUDE)) is not None: + if ( + latitude := state.attributes.get(EntityStateAttribute.LATITUDE) + ) is not None: self._metric( "geo_location_latitude_degrees", prometheus_client.Gauge, "Latitude of the geo location event in degrees", labels, ).set(latitude) - if (longitude := state.attributes.get(ATTR_LONGITUDE)) is not None: + if ( + longitude := state.attributes.get(EntityStateAttribute.LONGITUDE) + ) is not None: self._metric( "geo_location_longitude_degrees", prometheus_client.Gauge, From 0779d4831c1093797cb0fa2ad7433f982f2cd0f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:54 +0200 Subject: [PATCH 579/707] Fix restoring the location of Traccar device trackers (#176470) --- .../components/traccar/device_tracker.py | 20 +++--- .../components/traccar/test_device_tracker.py | 72 +++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 tests/components/traccar/test_device_tracker.py diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 45faad54767f..d260410f4338 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -5,8 +5,12 @@ from datetime import timedelta import logging from typing import override -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -16,12 +20,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, TRACKER_UPDATE from .const import ( - ATTR_ACCURACY, ATTR_ALTITUDE, - ATTR_BATTERY, ATTR_BEARING, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_SPEED, EVENT_ALARM, EVENT_ALL_EVENTS, @@ -162,15 +162,17 @@ class TraccarEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) - self._attr_location_accuracy = attr.get(ATTR_ACCURACY, 0) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) + self._attr_location_accuracy = attr.get( + TrackerEntityStateAttribute.GPS_ACCURACY, 0 + ) self._attr_extra_state_attributes = { ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), ATTR_BEARING: attr.get(ATTR_BEARING), ATTR_SPEED: attr.get(ATTR_SPEED), } - self._battery = attr.get(ATTR_BATTERY) + self._battery = attr.get(ATTR_BATTERY_LEVEL) @override async def async_will_remove_from_hass(self) -> None: diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py new file mode 100644 index 000000000000..6d830b6c8d6e --- /dev/null +++ b/tests/components/traccar/test_device_tracker.py @@ -0,0 +1,72 @@ +"""The tests for the Traccar device tracker platform.""" + +import pytest + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + TrackerEntityStateAttribute, +) +from homeassistant.components.device_tracker.legacy import Device +from homeassistant.components.traccar import DOMAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_WEBHOOK_ID, + STATE_NOT_HOME, + EntityStateAttribute, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_restore_cache + +DEVICE_ID = "device_1" +ENTITY_ID = f"{DEVICE_TRACKER_DOMAIN}.{DEVICE_ID}" + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: + """Mock device tracker config loading.""" + + +async def test_restore_state(hass: HomeAssistant) -> None: + """Test that the previous location is restored for a known device.""" + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, {}) + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_WEBHOOK_ID: "webhook_id"}) + entry.add_to_hass(hass) + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, DEVICE_ID)}, + ) + + mock_restore_cache( + hass, + [ + State( + ENTITY_ID, + STATE_NOT_HOME, + { + EntityStateAttribute.LATITUDE: 1.0, + EntityStateAttribute.LONGITUDE: 2.0, + TrackerEntityStateAttribute.GPS_ACCURACY: 30, + ATTR_BATTERY_LEVEL: 40, + "altitude": 50, + "bearing": 60, + "speed": 70, + }, + ) + ], + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[EntityStateAttribute.LATITUDE] == 1.0 + assert state.attributes[EntityStateAttribute.LONGITUDE] == 2.0 + assert state.attributes[TrackerEntityStateAttribute.GPS_ACCURACY] == 30 + assert state.attributes[ATTR_BATTERY_LEVEL] == 40 + assert state.attributes["altitude"] == 50 + assert state.attributes["bearing"] == 60 + assert state.attributes["speed"] == 70 From fbc2eb8d271c7b675bb31792bbe602f6465fb3ad Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 14 Jul 2026 10:00:45 +0200 Subject: [PATCH 580/707] Refactor perform action in MELCloud Home (#176449) --- .../components/melcloud_home/common.py | 39 ++++++++++++++++- .../components/melcloud_home/number.py | 37 ++-------------- .../components/melcloud_home/switch.py | 43 +++---------------- 3 files changed, 45 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/melcloud_home/common.py b/homeassistant/components/melcloud_home/common.py index d3e1417018a7..4a58bba960d8 100644 --- a/homeassistant/components/melcloud_home/common.py +++ b/homeassistant/components/melcloud_home/common.py @@ -1,13 +1,22 @@ """Commonly shared code for the MELCloud Home integration.""" -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable +from typing import Any -from aiomelcloudhome import ATAUnit, ATWUnit +from aiomelcloudhome import ( + ATAUnit, + ATWUnit, + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import MelCloudHomeCoordinator @@ -33,6 +42,32 @@ def async_setup_unit_entities( _async_add_new_atw_units(list(coordinator.atw_units.values())) +async def perform_action( + coordinator: MelCloudHomeCoordinator, + coroutine: Coroutine[Any, Any, None], +) -> None: + """Perform a MELCloud Home action with error handling and coordinator refresh.""" + try: + await coroutine + except MelCloudHomeAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except MelCloudHomeConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except MelCloudHomeTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + ) from err + else: + await coordinator.async_request_refresh() + + def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]: """Return the client keyword argument selecting this unit.""" if isinstance(unit, ATAUnit): diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py index 7cb18e6f0455..2c78d443922a 100644 --- a/homeassistant/components/melcloud_home/number.py +++ b/homeassistant/components/melcloud_home/number.py @@ -5,11 +5,6 @@ from dataclasses import dataclass from typing import Any, override from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome -from aiomelcloudhome.exceptions import ( - MelCloudHomeAuthenticationError, - MelCloudHomeConnectionError, - MelCloudHomeTimeoutError, -) from homeassistant.components.number import ( NumberDeviceClass, @@ -21,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities, unit_ids +from .common import async_setup_unit_entities, perform_action, unit_ids from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -182,32 +177,6 @@ ATW_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATWUnit], ...] = ( ) -async def _perform_action( - coordinator: MelCloudHomeCoordinator, - coroutine: Coroutine[Any, Any, None], -) -> None: - """Perform a MELCloud Home action with error handling and coordinator refresh.""" - try: - await coroutine - except MelCloudHomeAuthenticationError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except MelCloudHomeConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except MelCloudHomeTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_connect", - ) from err - else: - await coordinator.async_request_refresh() - - async def async_setup_entry( hass: HomeAssistant, entry: MelCloudHomeConfigEntry, @@ -269,7 +238,7 @@ class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): translation_domain=DOMAIN, translation_key=error_key, ) - await _perform_action( + await perform_action( self.coordinator, self.entity_description.set_value_fn( self.coordinator.client, self.unit, value @@ -315,7 +284,7 @@ class ATWNumber(MelCloudHomeATWUnitEntity, NumberEntity): translation_domain=DOMAIN, translation_key=error_key, ) - await _perform_action( + await perform_action( self.coordinator, self.entity_description.set_value_fn( self.coordinator.client, self.unit, value diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py index 67d130d77947..2d0f6ab230e0 100644 --- a/homeassistant/components/melcloud_home/switch.py +++ b/homeassistant/components/melcloud_home/switch.py @@ -5,11 +5,6 @@ from dataclasses import dataclass from typing import Any, override from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome -from aiomelcloudhome.exceptions import ( - MelCloudHomeAuthenticationError, - MelCloudHomeConnectionError, - MelCloudHomeTimeoutError, -) from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,11 +13,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities, unit_ids -from .const import DOMAIN +from .common import async_setup_unit_entities, perform_action, unit_ids from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -109,32 +102,6 @@ ATW_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATWUnit], ...] = ( ) -async def _perform_action( - coordinator: MelCloudHomeCoordinator, - coroutine: Coroutine[Any, Any, None], -) -> None: - """Perform a MELCloud Home action with error handling and coordinator refresh.""" - try: - await coroutine - except MelCloudHomeAuthenticationError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except MelCloudHomeConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except MelCloudHomeTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_connect", - ) from err - else: - await coordinator.async_request_refresh() - - async def async_setup_entry( hass: HomeAssistant, entry: MelCloudHomeConfigEntry, @@ -189,7 +156,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -197,7 +164,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) @@ -234,7 +201,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -242,7 +209,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) From 9eb29b8494a67682d592f6784f3690100284d37c Mon Sep 17 00:00:00 2001 From: Martin Hoefling Date: Tue, 14 Jul 2026 10:09:11 +0200 Subject: [PATCH 581/707] Add PostgreSQL backend and storage backend selection for KNX telegrams (#175673) Co-authored-by: Claude Opus 4.8 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/knx/__init__.py | 16 +- homeassistant/components/knx/config_flow.py | 171 ++++++++++ homeassistant/components/knx/const.py | 14 + homeassistant/components/knx/diagnostics.py | 2 + homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/strings.json | 36 ++ homeassistant/components/knx/telegrams.py | 70 +++- homeassistant/components/knx/websocket.py | 13 +- requirements_all.txt | 2 +- tests/components/knx/conftest.py | 3 + .../knx/snapshots/test_diagnostic.ambr | 5 + tests/components/knx/test_config_flow.py | 319 ++++++++++++++++++ tests/components/knx/test_diagnostic.py | 6 + tests/components/knx/test_init.py | 29 ++ tests/components/knx/test_telegrams.py | 68 ++++ tests/components/knx/test_websocket.py | 39 +++ 16 files changed, 774 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 6ae46c3173be..6dff1f12f512 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -24,11 +24,13 @@ from .const import ( CONF_KNX_KNXKEY_FILENAME, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DATA_HASS_CONFIG, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_PATH_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, @@ -188,11 +190,23 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER) new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT) + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, version=2 + entry, data=new_data, options=new_options, version=2, minor_version=2 ) _LOGGER.info("Migration to version 2 successful") + if entry.version == 2 and entry.minor_version < 2: + # version 2.2 introduced in 2026.8 + new_options = {**entry.options} + if CONF_KNX_TELEGRAM_DB_BACKEND not in new_options: + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( + entry, options=new_options, minor_version=2 + ) + _LOGGER.info("Migration to version 2.2 successful") + return True diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 50a2c7206b44..c612f26714d4 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -1,8 +1,12 @@ """Config flow for KNX.""" +import asyncio from collections.abc import AsyncGenerator from typing import Any, Final, Literal, override +from urllib.parse import quote, unquote, urlparse, urlunparse +from knx_telegram_store import ConnectionErrorKind +from knx_telegram_store.backends.postgres import PostgresStore import voluptuous as vol from xknx import XKNX from xknx.exceptions.exception import ( @@ -49,8 +53,16 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_DATABASE, + CONF_KNX_TELEGRAM_DB_HOST, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_PASSWORD, + CONF_KNX_TELEGRAM_DB_PORT, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + CONF_KNX_TELEGRAM_DB_TLS, + CONF_KNX_TELEGRAM_DB_USER, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, @@ -58,6 +70,8 @@ from .const import ( DEFAULT_ROUTING_IA, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -82,12 +96,17 @@ DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions( state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT, telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + telegram_db_backend=KNX_TELEGRAM_BACKEND_SQLITE, ) CONF_KEYRING_FILE: Final = "knxkeys_file" CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section" +# Timeout for the PostgreSQL connection check, so an unreachable host cannot +# block the options flow until the driver/OS connection timeout expires. +DSN_CHECK_TIMEOUT = 10 + CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { CONF_KNX_TUNNELING: "UDP (Tunneling v1)", @@ -113,6 +132,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize KNX config flow.""" @@ -951,6 +971,7 @@ class KNXOptionsFlow(OptionsFlowWithReload): """Manage KNX communication settings.""" if user_input is not None: telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION] + backend = telegram_store_section[CONF_KNX_TELEGRAM_DB_BACKEND] self.new_entry_options |= KNXConfigEntryOptions( state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], @@ -960,7 +981,10 @@ class KNXOptionsFlow(OptionsFlowWithReload): telegram_db_retention_days=telegram_store_section[ CONF_KNX_TELEGRAM_DB_RETENTION_DAYS ], + telegram_db_backend=backend, ) + if backend == KNX_TELEGRAM_BACKEND_POSTGRES: + return await self.async_step_telegram_store_postgres() return self.finish_flow() data_schema = { @@ -1020,6 +1044,22 @@ class KNXOptionsFlow(OptionsFlowWithReload): ), vol.Coerce(int), ), + vol.Required( + CONF_KNX_TELEGRAM_DB_BACKEND, + default=self.initial_options.get( + CONF_KNX_TELEGRAM_DB_BACKEND, + KNX_TELEGRAM_BACKEND_SQLITE, + ), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + KNX_TELEGRAM_BACKEND_SQLITE, + KNX_TELEGRAM_BACKEND_POSTGRES, + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="telegram_backend", + ) + ), } ), ), @@ -1027,5 +1067,136 @@ class KNXOptionsFlow(OptionsFlowWithReload): return self.async_show_form( step_id="communication_settings", data_schema=vol.Schema(data_schema), + last_step=False, + ) + + async def async_step_telegram_store_postgres( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect and validate the PostgreSQL telegram store connection.""" + current_dsn = self.initial_options.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "") + parsed = _parse_dsn(current_dsn) + errors: dict[str, str] = {} + + if user_input is not None: + # Reuse the stored password when the field is left blank. + params = { + **user_input, + CONF_KNX_TELEGRAM_DB_PASSWORD: ( + user_input.get(CONF_KNX_TELEGRAM_DB_PASSWORD) + or parsed.get(CONF_KNX_TELEGRAM_DB_PASSWORD, "") + ), + } + dsn = _build_dsn(params) + errors = await _async_check_postgres_dsn(dsn) + if not errors: + self.new_entry_options |= KNXConfigEntryOptions( + telegram_db_postgres_dsn=dsn + ) + return self.finish_flow() + + data_schema = vol.Schema( + { + vol.Required( + CONF_KNX_TELEGRAM_DB_HOST, + default=parsed.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PORT, + default=parsed.get(CONF_KNX_TELEGRAM_DB_PORT, 5432), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=65535, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_USER, + default=parsed.get(CONF_KNX_TELEGRAM_DB_USER, ""), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PASSWORD, default="" + ): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_DATABASE, + default=parsed.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_TLS, + default=parsed.get(CONF_KNX_TELEGRAM_DB_TLS, False), + ): selector.BooleanSelector(), + } + ) + if user_input is not None: + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + return self.async_show_form( + step_id="telegram_store_postgres", + data_schema=data_schema, + errors=errors, last_step=True, ) + + +async def _async_check_postgres_dsn(dsn: str) -> dict[str, str]: + """Validate a PostgreSQL DSN, returning form errors on failure.""" + connection_errors = { + ConnectionErrorKind.AUTH: "invalid_auth", + ConnectionErrorKind.HOST_UNREACHABLE: "host_unreachable", + ConnectionErrorKind.DATABASE_MISSING: "database_missing", + ConnectionErrorKind.PERMISSION: "permission", + ConnectionErrorKind.TIMEOUT: "timeout", + ConnectionErrorKind.MISSING_DEPENDENCY: "missing_dependency", + } + try: + async with asyncio.timeout(DSN_CHECK_TIMEOUT): + check_result = await PostgresStore.check_config(dsn) + except TimeoutError: + return {"base": "timeout"} + except ValueError: + return {"base": "cannot_connect"} + if not check_result.ok: + return {"base": connection_errors.get(check_result.kind, "cannot_connect")} + return {} + + +def _build_dsn(params: dict[str, Any]) -> str: + """Build a PostgreSQL DSN from form params.""" + quoted_user = quote(params.get(CONF_KNX_TELEGRAM_DB_USER, ""), safe="") + quoted_password = quote(params.get(CONF_KNX_TELEGRAM_DB_PASSWORD, ""), safe="") + host = params.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost") + if ":" in host and not host.startswith("["): + # IPv6 literals must be bracketed in the URL netloc + host = f"[{host}]" + port = int(params.get(CONF_KNX_TELEGRAM_DB_PORT, 5432)) + quoted_database = quote( + params.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), safe="" + ) + tls = params.get(CONF_KNX_TELEGRAM_DB_TLS, False) + + netloc = f"{quoted_user}:{quoted_password}@{host}:{port}" + query = "sslmode=require" if tls else "" + return urlunparse(("postgresql", netloc, f"/{quoted_database}", "", query, "")) + + +def _parse_dsn(dsn: str) -> dict[str, Any]: + """Parse a PostgreSQL DSN into form params.""" + if not dsn: + return {} + try: + url = urlparse(dsn) + return { + CONF_KNX_TELEGRAM_DB_USER: unquote(url.username or ""), + CONF_KNX_TELEGRAM_DB_PASSWORD: unquote(url.password or ""), + CONF_KNX_TELEGRAM_DB_HOST: url.hostname or "localhost", + CONF_KNX_TELEGRAM_DB_PORT: url.port or 5432, + CONF_KNX_TELEGRAM_DB_DATABASE: unquote(url.path.lstrip("/")), + CONF_KNX_TELEGRAM_DB_TLS: "sslmode=require" in url.query, + } + except ValueError, AttributeError: + return {} diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 84f73b4255e2..f1c203d18a33 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -53,8 +53,20 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0 DEFAULT_ROUTING_IA: Final = "0.0.240" +CONF_KNX_TELEGRAM_DB_BACKEND: Final = "telegram_db_backend" CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days" CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours" +CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: Final = "telegram_db_postgres_dsn" + +CONF_KNX_TELEGRAM_DB_HOST: Final = "host" +CONF_KNX_TELEGRAM_DB_PORT: Final = "port" +CONF_KNX_TELEGRAM_DB_USER: Final = "user" +CONF_KNX_TELEGRAM_DB_PASSWORD: Final = "password" +CONF_KNX_TELEGRAM_DB_DATABASE: Final = "database" +CONF_KNX_TELEGRAM_DB_TLS: Final = "tls" + +KNX_TELEGRAM_BACKEND_SQLITE: Final = "sqlite" +KNX_TELEGRAM_BACKEND_POSTGRES: Final = "postgres" KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day @@ -139,6 +151,8 @@ class KNXConfigEntryOptions(TypedDict, total=False): # Integration only (not forwarded to xknx) telegram_db_retention_days: int telegram_db_load_hours: int + telegram_db_backend: str # sqlite | postgres + telegram_db_postgres_dsn: str class ColorTempModes(Enum): diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index c685a5123b0c..d637eb551888 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -15,6 +15,7 @@ from .const import ( CONF_KNX_ROUTING_BACKBONE_KEY, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DOMAIN, KNX_MODULE_KEY, ) @@ -24,6 +25,7 @@ TO_REDACT = { CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a67d99cc3c6a..e0f5ba00e766 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.16.0", "xknxproject==3.9.0", "knx-frontend==2026.6.23.203726", - "knx-telegram-store[sqlite]==0.3.2" + "knx-telegram-store[sqlite,postgres]==0.9.1" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 59ff173b8b20..6cf052bfb633 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1162,6 +1162,15 @@ } }, "options": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "database_missing": "The specified database does not exist.", + "host_unreachable": "Could not reach the database host.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_dependency": "Required database driver is not installed.", + "permission": "Insufficient privileges to access the database.", + "timeout": "Connection timed out." + }, "step": { "communication_settings": { "data": { @@ -1175,10 +1184,12 @@ "sections": { "telegram_store_section": { "data": { + "telegram_db_backend": "Telegram storage backend", "telegram_db_load_hours": "Group monitor history", "telegram_db_retention_days": "Retention period" }, "data_description": { + "telegram_db_backend": "Select where to store KNX telegram history.", "telegram_db_load_hours": "Number of hours of telegram history to load when the group monitor is opened.", "telegram_db_retention_days": "Number of days to keep telegram history. Older telegrams are automatically deleted nightly at 3 AM. Set to `0` to delete all telegram history on every nightly run." }, @@ -1186,6 +1197,25 @@ } }, "title": "Communication settings" + }, + "telegram_store_postgres": { + "data": { + "database": "Database name", + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "tls": "Use TLS", + "user": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "database": "Name of the database to store telegrams in.", + "host": "Hostname or IP address of the PostgreSQL server.", + "password": "Password for the PostgreSQL user. Leave blank to keep the current password.", + "port": "Port the PostgreSQL server is listening on.", + "tls": "Encrypt the connection to the PostgreSQL server (`sslmode=require`). Note that the server certificate is not verified.", + "user": "Username to authenticate with the PostgreSQL server." + }, + "title": "PostgreSQL connection" } } }, @@ -1260,6 +1290,12 @@ "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" } + }, + "telegram_backend": { + "options": { + "postgres": "PostgreSQL (External)", + "sqlite": "Internal storage (Default)" + } } }, "services": { diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 3d48589d2451..0e7acb36dfe8 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -8,6 +8,7 @@ import os from typing import Any, TypedDict from knx_telegram_store import ( + BufferedPostgresStore, BufferedSqliteStore, KnxTelegramStoreException, StoredTelegram, @@ -26,7 +27,10 @@ from homeassistant.helpers.storage import STORAGE_DIR, Store from homeassistant.util import dt as dt_util from .const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + KNX_TELEGRAM_BACKEND_POSTGRES, KNX_TELEGRAM_DB_PATH_SQLITE, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, SIGNAL_KNX_TELEGRAM, @@ -48,6 +52,15 @@ EVICT_EXPIRED_HOUR = 3 # at risk from a longer interval are those buffered during an ungraceful shutdown. FLUSH_INTERVAL_SECONDS = 600 +# The buffer drops the oldest telegrams when full. Size it to cover a full +# flush interval at ~50 telegrams/s, the maximum rate of a KNX TP line, so +# nothing is dropped while the database is healthy. +MAX_BUFFER_TELEGRAMS = FLUSH_INTERVAL_SECONDS * 50 + +# Timeout for the migration probe and store initialization, so an unreachable +# database cannot block KNX setup until the driver/OS connection timeout expires. +STORE_INIT_TIMEOUT = 10 + class DecodedTelegramPayload(TypedDict): """Decoded payload value and metadata.""" @@ -89,19 +102,32 @@ class Telegrams: self.project = project self.config = config + self.backend: str = config[CONF_KNX_TELEGRAM_DB_BACKEND] + self.dsn: str = str(config.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "")) self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] - self.store: BufferedSqliteStore | None = None - self._uninitialized_store: BufferedSqliteStore | None = None + self.store: BufferedSqliteStore | BufferedPostgresStore | None = None + self._uninitialized_store: ( + BufferedSqliteStore | BufferedPostgresStore | None + ) = None self._evict_expired_unsub: CALLBACK_TYPE | None = None - full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) - os.makedirs(os.path.dirname(full_path), exist_ok=True) - self._uninitialized_store = BufferedSqliteStore( - full_path, - retention_days=self.retention_days, - flush_interval=FLUSH_INTERVAL_SECONDS, - ) + if self.backend == KNX_TELEGRAM_BACKEND_POSTGRES: + self._uninitialized_store = BufferedPostgresStore( + self.dsn, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) + else: + full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + self._uninitialized_store = BufferedSqliteStore( + full_path, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) self._xknx_telegram_cb_handle = ( xknx.telegram_queue.register_telegram_received_cb( @@ -121,7 +147,8 @@ class Telegrams: if self._uninitialized_store is None: return try: - needs_migration = await self._uninitialized_store.needs_migration() + async with asyncio.timeout(STORE_INIT_TIMEOUT): + needs_migration = await self._uninitialized_store.needs_migration() if needs_migration: _LOGGER.warning( "KNX telegram history database schema upgrade/migration is required. " @@ -129,24 +156,35 @@ class Telegrams: ) await self._uninitialized_store.initialize() else: - _LOGGER.debug("Initializing KNX telegram storage") - async with asyncio.timeout(10): + _LOGGER.debug( + "Initializing KNX telegram storage backend '%s'", + self.backend, + ) + async with asyncio.timeout(STORE_INIT_TIMEOUT): await self._uninitialized_store.initialize() - _LOGGER.info("Successfully initialized KNX telegram storage") + _LOGGER.info( + "Successfully initialized KNX telegram storage backend '%s'", + self.backend, + ) except TimeoutError: - _LOGGER.error("Timeout initializing KNX telegram storage") + _LOGGER.error( + "Timeout initializing KNX telegram storage backend '%s'", + self.backend, + ) await self._abort_store_init() return except KnxTelegramStoreException as err: _LOGGER.error( - "Database error initializing KNX telegram storage: %s", + "Database error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() return except Exception as err: # noqa: BLE001 _LOGGER.error( - "Error initializing KNX telegram storage: %s", + "Error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 4a79f7cdd9b0..568de4fe8220 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -8,7 +8,12 @@ import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel -from knx_telegram_store import KnxTelegramStoreException, TelegramQuery +from knx_telegram_store import ( + BufferedPostgresStore, + BufferedSqliteStore, + KnxTelegramStoreException, + TelegramQuery, +) import voluptuous as vol from xknx.telegram import Telegram from xknxproject.exceptions import XknxProjectException @@ -200,7 +205,11 @@ def ws_get_base_data( "connected": knx.xknx.connection_manager.connected.is_set(), "current_address": str(knx.xknx.current_address), "telegram_backend": ( - "sqlite" if knx.telegrams.store is not None else "unknown" + "sqlite" + if isinstance(knx.telegrams.store, BufferedSqliteStore) + else "postgres" + if isinstance(knx.telegrams.store, BufferedPostgresStore) + else "unknown" ), "telegram_retention": knx.telegrams.store.retention_days if knx.telegrams.store is not None diff --git a/requirements_all.txt b/requirements_all.txt index 766f784bb585..7a4230a825cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1435,7 +1435,7 @@ knocki==0.4.2 knx-frontend==2026.6.23.203726 # homeassistant.components.knx -knx-telegram-store[sqlite]==0.3.2 +knx-telegram-store[sqlite,postgres]==0.9.1 # homeassistant.components.kraken krakenex==2.2.2 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 7d69cda3d788..5cfab33adf34 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -32,10 +32,12 @@ from homeassistant.components.knx.const import ( CONF_KNX_MCAST_PORT, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DEFAULT_ROUTING_IA, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -364,6 +366,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, ) diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr index 314a856fe17f..1cc0d93c2382 100644 --- a/tests/components/knx/snapshots/test_diagnostic.ambr +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -10,6 +10,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -48,7 +49,9 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, + 'telegram_db_postgres_dsn': '**REDACTED**', 'telegram_db_retention_days': 10, }), 'config_store': dict({ @@ -79,6 +82,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -110,6 +114,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 982284db1803..27be1a6f5d4b 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1,8 +1,10 @@ """Test the KNX config flow.""" +import asyncio from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, Mock, patch +from knx_telegram_store.connection import ConnectionCheckResult, ConnectionErrorKind import pytest from xknx.exceptions import XKNXException from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration @@ -21,6 +23,8 @@ from homeassistant.components.knx.config_flow import ( DEFAULT_ENTRY_DATA, DEFAULT_ENTRY_OPTIONS, OPTION_MANUAL_TUNNEL, + _build_dsn, + _parse_dsn, ) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -41,13 +45,17 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -1065,6 +1073,7 @@ async def test_form_with_automatic_connection_handling( CONF_KNX_STATE_UPDATER: True, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } knx_setup.assert_called_once() @@ -1690,6 +1699,7 @@ async def test_options_communication_settings( CONF_KNX_TELEGRAM_STORE_SECTION: { CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, }, ) @@ -1699,6 +1709,7 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert mock_config_entry.data == initial_data assert mock_config_entry.options == { @@ -1706,5 +1717,313 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert len(knx_setup.mock_calls) == 2 + + +async def _advance_to_postgres_step( + hass: HomeAssistant, flow_id: str, *, retention_days: int = 14 +) -> config_entries.ConfigFlowResult: + """Select the PostgreSQL backend and land on its connection step.""" + result = await hass.config_entries.options.async_configure( + flow_id, + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_STORE_SECTION: { + CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: retention_days, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + }, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert not result["errors"] + return result + + +async def test_options_telegram_store_postgres( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow selecting the PostgreSQL telegram store backend.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_POSTGRES + ) + assert mock_config_entry.options[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] == 14 + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://knx:s3cret@db.local:5432/knx_telegrams?sslmode=require" + ) + assert len(knx_setup.mock_calls) == 2 + + +async def test_options_telegram_store_postgres_reuses_password( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL store reuses the stored password when left blank.""" + existing_dsn = "postgresql://olduser:oldpass@old.host:6543/olddb?sslmode=require" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: existing_dsn, + }, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"], retention_days=7) + + # Submit with an empty password - the existing one (parsed from the DSN) + # must be reused. + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "new.host", + "port": 5432, + "user": "newuser", + "password": "", + "database": "newdb", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://newuser:oldpass@new.host:5432/newdb" + ) + assert len(knx_setup.mock_calls) == 2 + + +@pytest.mark.parametrize( + ("error_kind", "expected_error"), + [ + pytest.param(ConnectionErrorKind.AUTH, "invalid_auth", id="invalid_auth"), + pytest.param( + ConnectionErrorKind.HOST_UNREACHABLE, + "host_unreachable", + id="host_unreachable", + ), + ], +) +async def test_options_telegram_store_postgres_connection_failure( + hass: HomeAssistant, + knx_setup: AsyncMock, + mock_config_entry: MockConfigEntry, + error_kind: ConnectionErrorKind, + expected_error: str, +) -> None: + """Test the PostgreSQL step maps connection check failures to form errors.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.failure(error_kind, "check failed"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "wrong_password", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": expected_error} + + +async def test_options_telegram_store_postgres_timeout( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow surfaces a timeout when the connection check hangs.""" + + async def hanging_check(dsn: str) -> None: + await asyncio.Event().wait() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with ( + patch("homeassistant.components.knx.config_flow.DSN_CHECK_TIMEOUT", 0.05), + patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + side_effect=hanging_check, + ), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "timeout"} + + +async def test_options_telegram_store_postgres_malformed_dsn( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL step maps a DSN the driver rejects to a form error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + # An unterminated bracketed IPv6 address makes engine creation + # raise ValueError before any connection attempt. + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "[::1", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.parametrize( + ("dsn", "expected"), + [ + pytest.param("", {}, id="empty"), + # Invalid port makes urlparse.port raise ValueError -> {} + pytest.param("postgresql://host:notaport/db", {}, id="invalid_port"), + pytest.param( + "postgresql://u:p@h:5432/db?sslmode=require", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db", + "tls": True, + }, + id="full", + ), + pytest.param( + "postgresql://user%40domain:p%40ss%25word@h:5432/db", + { + "user": "user@domain", + "password": "p@ss%word", + "host": "h", + "port": 5432, + "database": "db", + "tls": False, + }, + id="percent_encoded_credentials", + ), + pytest.param( + "postgresql://u:p@[2001:db8::1]:5432/db", + { + "user": "u", + "password": "p", + "host": "2001:db8::1", + "port": 5432, + "database": "db", + "tls": False, + }, + id="ipv6_host", + ), + pytest.param( + "postgresql://u:p@h:5432/db%3Fquery%23hash", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db?query#hash", + "tls": False, + }, + id="percent_encoded_database", + ), + ], +) +def test_parse_dsn(dsn: str, expected: dict) -> None: + """Test PostgreSQL DSN parsing, including malformed input.""" + assert _parse_dsn(dsn) == expected + + +@pytest.mark.parametrize( + ("user", "password", "host", "database"), + [ + pytest.param("simple", "plain", "localhost", "knx", id="plain"), + pytest.param("user@domain", "p@ss", "localhost", "knx", id="at_sign"), + pytest.param("user", "p@ss%word", "localhost", "knx", id="percent_sign"), + pytest.param( + "us:er", "p/a:s@s", "localhost", "knx", id="multiple_special_chars" + ), + pytest.param("user", "pass", "2001:db8::1", "knx", id="ipv6_host"), + pytest.param( + "user", "pass", "localhost", "knx?query#hash", id="database_special_chars" + ), + ], +) +def test_dsn_round_trip(user: str, password: str, host: str, database: str) -> None: + """Test _build_dsn -> _parse_dsn -> _build_dsn produces identical DSNs. + + Catches double percent-encoding: urlparse returns percent-encoded values, + so _parse_dsn must decode them before they are fed back into _build_dsn. + IPv6 hosts must be bracketed in the netloc for the DSN to stay parseable. + Database names with URL delimiters are percent-encoded to prevent truncation. + """ + params = { + "user": user, + "password": password, + "host": host, + "port": 5432, + "database": database, + "tls": False, + } + dsn1 = _build_dsn(params) + parsed = _parse_dsn(dsn1) + dsn2 = _build_dsn(parsed) + assert dsn1 == dsn2 diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index f35bad74eb46..2f1aa1e8c0a0 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -20,6 +20,7 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DEFAULT_ROUTING_IA, DOMAIN, ) @@ -100,6 +101,11 @@ async def test_diagnostic_redact( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_authentication", CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", }, + options={ + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: ( + "postgresql://knx:supersecret@localhost:5432/knx_telegrams" + ), + }, ) knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage) await knx.setup_integration() diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 5a114762f649..87ddd2f8c048 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -38,12 +38,14 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -437,3 +439,30 @@ async def test_async_migrate_entry_future_version(hass: HomeAssistant) -> None: with patch("homeassistant.components.knx.async_setup_entry", return_value=True): assert not await hass.config_entries.async_setup(config_entry.entry_id) + + +async def test_async_migrate_entry_v2_to_v2_2(hass: HomeAssistant) -> None: + """Test KNX config entry migration from v2.x to v2.2.""" + config_entry = MockConfigEntry( + title="KNX", + domain=DOMAIN, + version=2, + minor_version=1, + data={ + "other_setting": "some_value", + }, + options={ + "some_option": "value", + }, + ) + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.knx.async_setup_entry", return_value=True): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.version == 2 + assert config_entry.minor_version == 2 + assert ( + config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_SQLITE + ) diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index add2fff644f8..5912938256c2 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from copy import copy from datetime import datetime from unittest.mock import AsyncMock, patch @@ -11,9 +12,12 @@ from knx_telegram_store import KnxTelegramStoreException, StoredTelegram, Telegr import pytest from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR, ) from homeassistant.components.knx.telegrams import TelegramDict @@ -156,6 +160,34 @@ async def test_store_telegram_history_error_handling( assert issue is not None +async def test_store_telegram_history_needs_migration_timeout( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test that store initialization is aborted when needs_migration times out.""" + + async def hanging_probe() -> bool: + await asyncio.Event().wait() + return False + + with ( + patch("homeassistant.components.knx.telegrams.STORE_INIT_TIMEOUT", 0.05), + patch( + "knx_telegram_store.BufferedSqliteStore.needs_migration", + side_effect=hanging_probe, + ), + ): + await knx.setup_integration() + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + # Check that the repair issue was created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + assert issue is not None + + async def test_migrate_telegrams_from_json( hass: HomeAssistant, knx: KNXTestKit, @@ -483,3 +515,39 @@ async def test_nightly_eviction_error_handling( assert "Database error evicting expired KNX telegrams" in caplog.text # Store remains operational after the failed eviction assert telegrams_module.store is not None + + +async def test_postgres_backend_init_error( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test PostgreSQL backend DSN handling and init failure path.""" + dsn = "postgresql://user:secret@db.local:5432/knx" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: dsn, + }, + ) + + # Mock the store to avoid constructing a real SQLAlchemy engine / connecting. + mock_store = AsyncMock() + mock_store.needs_migration.return_value = False + mock_store.initialize.side_effect = KnxTelegramStoreException("no server") + with patch( + "homeassistant.components.knx.telegrams.BufferedPostgresStore", + return_value=mock_store, + ): + await knx.setup_integration(add_entry_to_hass=False) + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + is not None + ) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 0f5f9af1c37f..124122d99117 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -10,8 +10,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, KNX_ADDRESS, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, SUPPORTED_PLATFORMS_UI, ) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY @@ -37,10 +40,46 @@ async def test_knx_get_base_data_command( assert res["result"]["connection_info"]["version"] is not None assert res["result"]["connection_info"]["connected"] assert res["result"]["connection_info"]["current_address"] == "0.0.0" + assert res["result"]["connection_info"]["telegram_backend"] == "sqlite" assert res["result"]["project_info"] is None assert not SUPPORTED_PLATFORMS_UI.difference(res["result"]["supported_platforms"]) +async def test_knx_get_base_data_command_postgres( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +) -> None: + """Test knx/get_base_data reports the PostgreSQL telegram backend.""" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: "postgresql://user:pw@db.local:5432/knx", + }, + ) + # Patch methods on the real class so the isinstance check in the + # websocket handler still sees a BufferedPostgresStore instance. + with ( + patch( + "knx_telegram_store.BufferedPostgresStore.needs_migration", + return_value=False, + ), + patch("knx_telegram_store.BufferedPostgresStore.initialize"), + patch( + "knx_telegram_store.BufferedPostgresStore.get_last_unique_telegrams", + return_value=[], + ), + ): + await knx.setup_integration(add_entry_to_hass=False) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_base_data"}) + res = await client.receive_json() + + assert res["success"], res + assert res["result"]["connection_info"]["telegram_backend"] == "postgres" + + @pytest.mark.usefixtures("load_knxproj") async def test_knx_get_base_data_command_with_project( hass: HomeAssistant, From 062b347ba6bdfd7b7012b2574b0a4ed084f8add4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:45:00 +0200 Subject: [PATCH 582/707] Use entity state attribute enums in MQTT (#176467) --- .../components/mqtt/device_tracker.py | 33 ++++++++++++------- homeassistant/components/mqtt/diagnostics.py | 12 +++---- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 0efba71bbf72..8cf181e7699d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -7,16 +7,18 @@ from typing import TYPE_CHECKING, Any, override import voluptuous as vol from homeassistant.components import device_tracker -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import ( + SourceType, + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_HOME, STATE_NOT_HOME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -162,16 +164,18 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): ) -> None: """Extract the location from the extra state attributes.""" if ( - ATTR_LATITUDE in extra_state_attributes - or ATTR_LONGITUDE in extra_state_attributes + EntityStateAttribute.LATITUDE in extra_state_attributes + or EntityStateAttribute.LONGITUDE in extra_state_attributes ): latitude: float | None longitude: float | None gps_accuracy: float if isinstance( - latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) + latitude := extra_state_attributes.get(EntityStateAttribute.LATITUDE), + (int, float), ) and isinstance( - longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float) + longitude := extra_state_attributes.get(EntityStateAttribute.LONGITUDE), + (int, float), ): self._attr_latitude = latitude self._attr_longitude = longitude @@ -187,9 +191,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): extra_state_attributes, ) - if ATTR_GPS_ACCURACY in extra_state_attributes: + if TrackerEntityStateAttribute.GPS_ACCURACY in extra_state_attributes: if isinstance( - gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY], + gps_accuracy := extra_state_attributes[ + TrackerEntityStateAttribute.GPS_ACCURACY + ], (int, float), ): self._attr_location_accuracy = gps_accuracy @@ -210,5 +216,10 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._attr_extra_state_attributes = { attribute: value for attribute, value in extra_state_attributes.items() - if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE} + if attribute + not in { + TrackerEntityStateAttribute.GPS_ACCURACY, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, + } } diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 68d4b2fb9c7c..5ab4861201f4 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -5,12 +5,7 @@ from typing import Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_PASSWORD, - CONF_USERNAME, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry @@ -18,7 +13,10 @@ from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} -REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} +REDACT_STATE_DEVICE_TRACKER = { + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, +} async def async_get_config_entry_diagnostics( From 4fae33830264a94350629de4252b08641f84d7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 10:59:30 +0200 Subject: [PATCH 583/707] Flip nobo_hub Gold documentation quality scale rules to done (#176445) --- homeassistant/components/nobo_hub/quality_scale.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 1812cab9b10f..28d7df4e24d1 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -51,13 +51,13 @@ rules: diagnostics: todo discovery: done discovery-update-info: done - docs-data-update: todo + docs-data-update: done docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: todo entity-category: todo entity-device-class: done From 6f5992e66a9206f1cd4da011e251a37bab23200c Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 14 Jul 2026 11:03:57 +0200 Subject: [PATCH 584/707] Deprecate sensor attributes in Steam integration (#176417) --- homeassistant/components/steam_online/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 0d8c4ba8f4bd..45c1cdac4de0 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -60,6 +60,8 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( options=list(STEAM_STATUSES.values()), entity_picture_fn=lambda x, _: x.avatarfull, name=None, + # Attributes game, game_id, game_image_header, game_image_main, game_icon, + # last_online, and level are deprecated and can be removed in 2027.2 extra_state_attributes_fn=lambda x, icons: { "real_name": x.realname, "created": ( From 2f5ed221505db9821ab565e3526e2ccea1ec0f0e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:04:24 +0200 Subject: [PATCH 585/707] Bump uiprotect to 15.12.2 (#176420) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b279a5015c30..66513f125d71 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.12.1"] + "requirements": ["uiprotect==15.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a4230a825cc..b399346d9045 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3258,7 +3258,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.12.1 +uiprotect==15.12.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From b1fe6bbd17656e978c65d31af4a8935d22dc7a3a Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:56:03 +0200 Subject: [PATCH 586/707] Rename "advanced_options" section to "additional_options" in Template (#176302) --- homeassistant/components/template/__init__.py | 17 ++- .../components/template/config_flow.py | 17 +-- homeassistant/components/template/const.py | 2 +- homeassistant/components/template/helpers.py | 6 +- .../components/template/strings.json | 142 +++++++++--------- tests/components/template/test_config_flow.py | 4 +- .../template/test_device_tracker.py | 2 +- tests/components/template/test_init.py | 39 ++++- 8 files changed, 138 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 1ba5fa21e824..b825552e8170 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -29,7 +29,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS +from .const import ( + CONF_ADDITIONAL_OPTIONS, + CONF_MAX, + CONF_MIN, + CONF_STEP, + DOMAIN, + PLATFORMS, +) from .coordinator import TriggerUpdateCoordinator from .helpers import async_get_blueprints @@ -141,6 +148,14 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, version=1, minor_version=2 ) + options = {**config_entry.options} + # The "advanced_options" section was renamed to "additional_options" + if (additional := options.pop("advanced_options", None)) is not None: + options[CONF_ADDITIONAL_OPTIONS] = additional + hass.config_entries.async_update_entry( + config_entry, options=options, version=2, minor_version=1 + ) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 0d28bc3c6aa9..934cd6a3f6bf 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -60,7 +60,7 @@ from .alarm_control_panel import ( ) from .binary_sensor import async_create_preview_binary_sensor from .const import ( - CONF_ADVANCED_OPTIONS, + CONF_ADDITIONAL_OPTIONS, CONF_AVAILABILITY, CONF_PRESS, CONF_TURN_OFF, @@ -157,7 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = { def generate_schema(domain: str, flow_type: str) -> vol.Schema: """Generate schema.""" schema: dict[vol.Marker, Any] = {} - advanced_options: dict[vol.Marker, Any] = {} + additional_options: dict[vol.Marker, Any] = {} if flow_type == "config": schema = {vol.Required(CONF_NAME): selector.TextSelector()} @@ -240,7 +240,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_LATITUDE): selector.TemplateSelector(), vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(), } - advanced_options |= { + additional_options |= { vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(), } @@ -445,11 +445,11 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: schema |= { vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - vol.Optional(CONF_ADVANCED_OPTIONS): section( + vol.Optional(CONF_ADDITIONAL_OPTIONS): section( vol.Schema( { vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), - **advanced_options, + **additional_options, } ), {"collapsed": True}, @@ -782,8 +782,7 @@ class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW options_flow_reloads = True - MINOR_VERSION = 2 - VERSION = 1 + VERSION = 2 @callback @override @@ -901,9 +900,9 @@ def ws_start_preview( return config: dict = msg["user_input"] - advanced_options = config.pop(CONF_ADVANCED_OPTIONS, {}) + additional_options = config.pop(CONF_ADDITIONAL_OPTIONS, {}) preview_entity = CREATE_PREVIEW_ENTITY[template_type]( - hass, name, {**config, **advanced_options} + hass, name, {**config, **additional_options} ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index cbb9c3beb272..816b77b5284d 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.helpers.typing import ConfigType -CONF_ADVANCED_OPTIONS = "advanced_options" +CONF_ADDITIONAL_OPTIONS = "additional_options" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 959fbcb0bc37..66ca4eec45b5 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -31,7 +31,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_ADVANCED_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN +from .const import CONF_ADDITIONAL_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity @@ -240,8 +240,8 @@ async def async_setup_template_entry( options = dict(config_entry.options) options.pop("template_type") - if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None): - options = {**options, **advanced_options} + if additional_options := options.pop(CONF_ADDITIONAL_OPTIONS, None): + options = {**options, **additional_options} if replace_value_template and CONF_VALUE_TEMPLATE in options: options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 6de00e8fdc77..8c9028e03b9f 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,6 +1,6 @@ { "common": { - "advanced_options": "Advanced options", + "additional_options": "Additional options", "availability": "Availability template", "availability_description": "Defines a template to get the `available` state of the entity. If the template either fails to render or returns `True`, `\"1\"`, `\"true\"`, `\"yes\"`, `\"on\"`, `\"enable\"`, or a non-zero number, the entity will be `available`. If the template returns any other value, the entity will be `unavailable`. If not configured, the entity will always be `available`. Note that the string comparison is not case sensitive; `\"TrUe\"` and `\"yEs\"` are allowed.", "code_format": "Code format", @@ -42,14 +42,14 @@ "value_template": "Defines a template to set the state of the alarm panel. Valid output values from the template are `armed_away`, `armed_home`, `armed_night`, `armed_vacation`, `arming`, `disarmed`, `pending`, and `triggered`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template alarm control panel" @@ -66,14 +66,14 @@ "state": "The sensor is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template binary sensor" @@ -90,14 +90,14 @@ "press": "Defines actions to run when button is pressed." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template button" @@ -124,14 +124,14 @@ "stop_cover": "Defines actions to run when the cover is stopped." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template cover" @@ -152,7 +152,7 @@ "name": "[%key:common::config_flow::data::name%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]", "location_accuracy": "Location accuracy" @@ -161,7 +161,7 @@ "availability": "[%key:component::template::common::availability_description%]", "location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`." }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template device tracker" @@ -180,14 +180,14 @@ "event_types": "Defines a template for a list of available event types." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template event" @@ -213,14 +213,14 @@ "turn_on": "Defines actions to run when the fan is turned on. Receives variables `percentage` and/or `preset_mode`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template fan" @@ -238,14 +238,14 @@ "verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template image" @@ -277,14 +277,14 @@ "turn_on": "Defines actions to run when the light is turned on." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template light" @@ -308,14 +308,14 @@ "unlock": "Defines actions to run when the lock is unlocked." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template lock" @@ -342,14 +342,14 @@ "unit_of_measurement": "Defines the unit of measurement of the number, if any." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template number" @@ -369,14 +369,14 @@ "state": "Template for the select’s current value." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template select" @@ -396,14 +396,14 @@ "unit_of_measurement": "Defines the unit of measurement for the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template sensor" @@ -423,14 +423,14 @@ "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template switch" @@ -465,14 +465,14 @@ "update_percentage": "Defines a template to get the update completion percentage." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template update" @@ -529,14 +529,14 @@ "stop": "Defines actions to run when the vacuum is stopped." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template vacuum" @@ -562,11 +562,11 @@ "temperature_unit": "The temperature unit" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template weather" @@ -621,14 +621,14 @@ "value_template": "[%key:component::template::config::step::alarm_control_panel::data_description::value_template%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::alarm_control_panel::title%]" @@ -644,14 +644,14 @@ "state": "[%key:component::template::config::step::binary_sensor::data_description::state%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::binary_sensor::title%]" @@ -666,14 +666,14 @@ "press": "[%key:component::template::config::step::button::data_description::press%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::button::title%]" @@ -698,14 +698,14 @@ "stop_cover": "[%key:component::template::config::step::cover::data_description::stop_cover%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::cover::title%]" @@ -724,16 +724,16 @@ "longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]", - "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]" + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data::location_accuracy%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]", - "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]" + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data_description::location_accuracy%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::device_tracker::title%]" @@ -751,14 +751,14 @@ "event_types": "[%key:component::template::config::step::event::data_description::event_types%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::event::title%]" @@ -783,14 +783,14 @@ "turn_on": "[%key:component::template::config::step::fan::data_description::turn_on%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::fan::title%]" @@ -807,14 +807,14 @@ "verify_ssl": "[%key:component::template::config::step::image::data_description::verify_ssl%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::image::title%]" @@ -846,14 +846,14 @@ "turn_on": "[%key:component::template::config::step::light::data_description::turn_on%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::light::title%]" @@ -876,14 +876,14 @@ "unlock": "[%key:component::template::config::step::lock::data_description::unlock%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::lock::title%]" @@ -908,14 +908,14 @@ "step": "[%key:component::template::config::step::number::data_description::step%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::number::title%]" @@ -935,14 +935,14 @@ "state": "[%key:component::template::config::step::select::data_description::state%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::select::title%]" @@ -961,14 +961,14 @@ "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::unit_of_measurement%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::sensor::title%]" @@ -988,14 +988,14 @@ "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::switch::title%]" @@ -1030,14 +1030,14 @@ "update_percentage": "[%key:component::template::config::step::update::data_description::update_percentage%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template update" @@ -1071,14 +1071,14 @@ "stop": "[%key:component::template::config::step::vacuum::data_description::stop%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::vacuum::title%]" @@ -1104,11 +1104,11 @@ "temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::weather::title%]" diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 934c5f9ed92d..bbcdf7cfab25 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -321,7 +321,7 @@ async def test_config_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - availability = {"advanced_options": {"availability": "{{ True }}"}} + availability = {"additional_options": {"availability": "{{ True }}"}} with patch( "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry @@ -1103,7 +1103,7 @@ async def test_config_flow_preview( assert result["preview"] == "template" availability = { - "advanced_options": { + "additional_options": { "availability": "{{ is_state('binary_sensor.available', 'on') }}" } } diff --git a/tests/components/template/test_device_tracker.py b/tests/components/template/test_device_tracker.py index 59eab709bf54..1eb3d54b490d 100644 --- a/tests/components/template/test_device_tracker.py +++ b/tests/components/template/test_device_tracker.py @@ -155,7 +155,7 @@ async def test_setup_config_entry( options={ "name": TEST_TRACKER.object_id, **TEST_MINIMUM_REQUIREMENTS, - "advanced_options": {"location_accuracy": "{{ 10 }}"}, + "additional_options": {"location_accuracy": "{{ 10 }}"}, "template_type": device_tracker.DOMAIN, }, title="My template", diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 5c27ef80248a..053c81280ba7 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -578,8 +578,41 @@ async def test_migration_1_1( template_entity_entry = entity_registry.async_get("sensor.my_template") assert template_entity_entry.device_id == device_entry.id - assert template_config_entry.version == 1 - assert template_config_entry.minor_version == 2 + assert template_config_entry.version == 2 + assert template_config_entry.minor_version == 1 + + +async def test_migration_1_2( + hass: HomeAssistant, +) -> None: + """Test migration from v1.2 renames the advanced_options section.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "template_type": "sensor", + "state": "{{ 'foo' }}", + "advanced_options": {"availability": "{{ True }}"}, + }, + title="My template", + version=1, + minor_version=2, + ) + template_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + assert template_config_entry.state is ConfigEntryState.LOADED + assert "advanced_options" not in template_config_entry.options + assert template_config_entry.options["additional_options"] == { + "availability": "{{ True }}" + } + + assert template_config_entry.version == 2 + assert template_config_entry.minor_version == 1 async def test_migration_from_future_version( @@ -595,7 +628,7 @@ async def test_migration_from_future_version( "state": "{{ 'foo' }}", }, title="My template", - version=2, + version=3, minor_version=1, ) config_entry.add_to_hass(hass) From b7bcb420b0591b6f278dff3e87ab33e528679306 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 14 Jul 2026 12:55:11 +0200 Subject: [PATCH 587/707] Bump pyoverkiz to 2.1.0 (#176481) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 4b843588c16d..0468340432a1 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -14,7 +14,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz[nexity]==2.0.4"], + "requirements": ["pyoverkiz[nexity]==2.1.0"], "zeroconf": [ { "name": "gateway*", diff --git a/requirements_all.txt b/requirements_all.txt index b399346d9045..0d36df9a13cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2454,7 +2454,7 @@ pyotgw==2.2.3 pyotp==2.9.0 # homeassistant.components.overkiz -pyoverkiz[nexity]==2.0.4 +pyoverkiz[nexity]==2.1.0 # homeassistant.components.palazzetti pypalazzetti==0.1.20 From 59d025b250c15536b6fd9ac547a4e5a31d45593d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 14 Jul 2026 12:55:32 +0200 Subject: [PATCH 588/707] Remove strict-typing exception from backblaze_b2 (#176482) --- homeassistant/components/backblaze_b2/quality_scale.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze_b2/quality_scale.yaml index 1532f08d9a82..ef20474b3130 100644 --- a/homeassistant/components/backblaze_b2/quality_scale.yaml +++ b/homeassistant/components/backblaze_b2/quality_scale.yaml @@ -123,8 +123,4 @@ rules: comment: | The b2sdk library does not support custom HTTP session injection. It manages HTTP connections internally through its own session management. - strict-typing: - status: exempt - comment: | - The b2sdk dependency does not include a py.typed file and is not PEP 561 compliant. - This is outside the integration's control as it's a third-party library requirement. + strict-typing: todo From 66fefda1179d5df11b5c8ca6602b877fc4f367ca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Jul 2026 12:57:38 +0200 Subject: [PATCH 589/707] Bump modbus-connection to 3.6.0 (#176448) Co-authored-by: Claude --- homeassistant/components/modbus_connection/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json index a3f132e4e6d9..7d78cd95d247 100644 --- a/homeassistant/components/modbus_connection/manifest.json +++ b/homeassistant/components/modbus_connection/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["modbus_connection", "tmodbus"], "quality_scale": "bronze", - "requirements": ["modbus-connection[tmodbus]==3.4.1"] + "requirements": ["modbus-connection[tmodbus]==3.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0d36df9a13cd..1e4da521b809 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1598,7 +1598,7 @@ mitsubishi-comfort==0.3.2 moat-ble==0.1.1 # homeassistant.components.modbus_connection -modbus-connection[tmodbus]==3.4.1 +modbus-connection[tmodbus]==3.6.0 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 From 8c47308c506a20afb279123c8408f9300e1a18ad Mon Sep 17 00:00:00 2001 From: Maor Date: Tue, 14 Jul 2026 14:58:42 +0300 Subject: [PATCH 590/707] Retry rympro setup on OperationError instead of failing permanently (#176478) --- homeassistant/components/rympro/__init__.py | 6 +- tests/components/rympro/test_init.py | 69 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/components/rympro/test_init.py diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index 69251608d09e..57564aeab24b 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -2,7 +2,7 @@ import logging -from pyrympro import CannotConnectError, RymPro, UnauthorizedError +from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant @@ -22,13 +22,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bo rympro.set_token(data[CONF_TOKEN]) try: await rympro.account_info() - except CannotConnectError as error: + except (CannotConnectError, OperationError) as error: raise ConfigEntryNotReady from error except UnauthorizedError: try: token = await rympro.login(data[CONF_EMAIL], data[CONF_PASSWORD], "ha") except UnauthorizedError as error: raise ConfigEntryAuthFailed from error + except CannotConnectError as error: + raise ConfigEntryNotReady from error hass.config_entries.async_update_entry( entry, data={**data, CONF_TOKEN: token}, diff --git a/tests/components/rympro/test_init.py b/tests/components/rympro/test_init.py new file mode 100644 index 000000000000..d966f9cf7e30 --- /dev/null +++ b/tests/components/rympro/test_init.py @@ -0,0 +1,69 @@ +"""Test the Read Your Meter Pro integration setup.""" + +from unittest.mock import patch + +from pyrympro import CannotConnectError, OperationError, UnauthorizedError +import pytest + +from homeassistant.components.rympro.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + CONF_TOKEN: "test-token", + CONF_UNIQUE_ID: "test-account-number", +} + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_DATA, + unique_id=TEST_DATA[CONF_UNIQUE_ID], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.mark.parametrize("exception", [CannotConnectError, OperationError]) +async def test_account_info_error_retries_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + exception: type[Exception], +) -> None: + """Test that a transient account_info error schedules a setup retry.""" + with patch( + "homeassistant.components.rympro.RymPro.account_info", + side_effect=exception, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_relogin_cannot_connect_error_retries_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that a connection error while re-authenticating retries setup.""" + with ( + patch( + "homeassistant.components.rympro.RymPro.account_info", + side_effect=UnauthorizedError, + ), + patch( + "homeassistant.components.rympro.RymPro.login", + side_effect=CannotConnectError, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 62101274ef0a2d033917e1e0cc699177e337b189 Mon Sep 17 00:00:00 2001 From: Amit Krishna <218109745+amitkio@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:29:06 +0530 Subject: [PATCH 591/707] Add repair-issue reporting to energieleser (#176476) --- .../components/energieleser/__init__.py | 3 + .../components/energieleser/coordinator.py | 21 ++++++ .../energieleser/quality_scale.yaml | 2 +- .../components/energieleser/strings.json | 6 ++ tests/components/energieleser/conftest.py | 9 +++ tests/components/energieleser/test_init.py | 66 ++++++++++++++++++- 6 files changed, 104 insertions(+), 3 deletions(-) mode change 100755 => 100644 tests/components/energieleser/test_init.py diff --git a/homeassistant/components/energieleser/__init__.py b/homeassistant/components/energieleser/__init__.py index f9167316eca0..5533dd6e91dc 100644 --- a/homeassistant/components/energieleser/__init__.py +++ b/homeassistant/components/energieleser/__init__.py @@ -4,8 +4,10 @@ from energieleser import EnergieleserClient from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN from .coordinator import EnergieleserConfigEntry, EnergieleserCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -30,4 +32,5 @@ async def async_unload_entry( hass: HomeAssistant, entry: EnergieleserConfigEntry ) -> bool: """Unload an energieleser config entry.""" + ir.async_delete_issue(hass, DOMAIN, f"pin_locked_{entry.entry_id}") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energieleser/coordinator.py b/homeassistant/components/energieleser/coordinator.py index e71e076425d5..6c026a69fa47 100755 --- a/homeassistant/components/energieleser/coordinator.py +++ b/homeassistant/components/energieleser/coordinator.py @@ -9,11 +9,13 @@ from energieleser import ( EnergieleserDevice, EnergieleserError, EnergieleserUnknownDeviceError, + StromleserOneDevice, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -74,4 +76,23 @@ class EnergieleserCoordinator(DataUpdateCoordinator[EnergieleserDevice]): "device_id": self.device_id, }, ) from err + if isinstance(device, StromleserOneDevice): + issue_id = f"pin_locked_{self.config_entry.entry_id}" + if device.pin_locked: + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + learn_more_url="https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation", + severity=ir.IssueSeverity.WARNING, + translation_key="meter_locked", + translation_placeholders={ + "device_name": self.config_entry.title, + }, + ) + else: + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + return device diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index 7173e0886296..0a83fc235811 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -69,7 +69,7 @@ rules: exception-translations: done icon-translations: todo reconfiguration-flow: done - repair-issues: todo + repair-issues: done stale-devices: status: exempt comment: One device per config entry; the device is removed when the entry is removed. diff --git a/homeassistant/components/energieleser/strings.json b/homeassistant/components/energieleser/strings.json index 7065aec00bc3..370ec96f4b9b 100755 --- a/homeassistant/components/energieleser/strings.json +++ b/homeassistant/components/energieleser/strings.json @@ -103,5 +103,11 @@ "unknown_device": { "message": "The device type for {device_id} is unknown or unsupported" } + }, + "issues": { + "meter_locked": { + "description": "The electricity meter connected to {device_name} is not providing high-resolution data. You need to unlock the physical meter by entering the PIN (provided by your electricity company or grid operator) directly on the meter. Once the meter is unlocked, high-resolution data will be provided and this issue will resolve itself automatically. See the linked instructions for details on how to enter the PIN.", + "title": "Meter PIN Required" + } } } diff --git a/tests/components/energieleser/conftest.py b/tests/components/energieleser/conftest.py index 21e8874273ad..f26a1c4117de 100644 --- a/tests/components/energieleser/conftest.py +++ b/tests/components/energieleser/conftest.py @@ -1,6 +1,7 @@ """Fixtures for energieleser integration tests.""" from collections.abc import Generator +from dataclasses import replace from unittest.mock import AsyncMock, patch from energieleser import ( @@ -78,6 +79,14 @@ def mock_stromleser_device() -> StromleserOneDevice: return StromleserOneDevice.from_payload(STROMLESER_API_RESPONSE) +@pytest.fixture +def mock_locked_stromleser_device( + mock_stromleser_device: StromleserOneDevice, +) -> StromleserOneDevice: + """Return a stromleser device with PIN locked.""" + return replace(mock_stromleser_device, pin_locked=True) + + @pytest.fixture def mock_gasleser_device() -> GasleserDevice: """Return a parsed gasleser device built from the API fixture.""" diff --git a/tests/components/energieleser/test_init.py b/tests/components/energieleser/test_init.py old mode 100755 new mode 100644 index b620df729924..ce229a0dfc25 --- a/tests/components/energieleser/test_init.py +++ b/tests/components/energieleser/test_init.py @@ -6,18 +6,21 @@ from energieleser import ( EnergieleserConnectionError, EnergieleserError, EnergieleserUnknownDeviceError, + StromleserOneDevice, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.energieleser.const import CONF_SW_VERSION, DOMAIN +from homeassistant.components.energieleser.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from .conftest import STROMLESER_DEVICE_ID, STROMLESER_SW_VERSION -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_energieleser_client") @@ -89,3 +92,62 @@ async def test_device_exposes_discovery_sw_version( ) assert device is not None assert device.sw_version == STROMLESER_SW_VERSION + + +async def test_meter_locked_repair_issue( + hass: HomeAssistant, + mock_energieleser_client: AsyncMock, + mock_stromleser_device: StromleserOneDevice, + mock_locked_stromleser_device: StromleserOneDevice, + mock_stromleser_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test repair issue is created when meter is locked and deleted when unlocked.""" + mock_energieleser_client.get_device.return_value = mock_locked_stromleser_device + mock_stromleser_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + issue_id = f"pin_locked_{mock_stromleser_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "meter_locked" + assert ( + issue.learn_more_url + == "https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation" + ) + assert issue.translation_placeholders == { + "device_name": mock_stromleser_config_entry.title, + } + + mock_energieleser_client.get_device.return_value = mock_stromleser_device + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_meter_locked_repair_issue_removed_on_unload( + hass: HomeAssistant, + mock_energieleser_client: AsyncMock, + mock_locked_stromleser_device: StromleserOneDevice, + mock_stromleser_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is deleted when entry is unloaded.""" + mock_energieleser_client.get_device.return_value = mock_locked_stromleser_device + mock_stromleser_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + issue_id = f"pin_locked_{mock_stromleser_config_entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + assert await hass.config_entries.async_unload(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None From d3c18b6840ee71ceb20cb38f4c2454a89ce31ec2 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Tue, 14 Jul 2026 14:59:41 +0200 Subject: [PATCH 592/707] Set PARALLEL_UPDATES = 1 for Blebox update (#176493) --- homeassistant/components/blebox/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blebox/update.py b/homeassistant/components/blebox/update.py index e7e0088d3290..389b0560c6fe 100644 --- a/homeassistant/components/blebox/update.py +++ b/homeassistant/components/blebox/update.py @@ -22,7 +22,7 @@ from .const import DOMAIN from .coordinator import BleBoxCoordinator from .entity import BleBoxEntity -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(hours=1) From 8d9f94e40fd789672b2dad463c8fe8d6230f4401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 15:04:09 +0200 Subject: [PATCH 593/707] Flip nobo_hub docs-examples quality scale rule to done (#176479) --- homeassistant/components/nobo_hub/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 28d7df4e24d1..aa91d4b5a5ac 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -52,7 +52,7 @@ rules: discovery: done discovery-update-info: done docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done From c83297eacbfbe2af2ed88153ed193ef32bf9f691 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 14 Jul 2026 15:06:06 +0200 Subject: [PATCH 594/707] Version bump pydaikin to 2.18.2 (#176492) --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index dfb353b8719e..ade738d2f7f9 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.18.1"], + "requirements": ["pydaikin==2.18.2"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 1e4da521b809..4f8d4e733c57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2109,7 +2109,7 @@ pycsspeechtts==1.0.8 pycync==0.5.0 # homeassistant.components.daikin -pydaikin==2.18.1 +pydaikin==2.18.2 # homeassistant.components.danfoss_air pydanfossair==0.1.0 From d1a60af873551863f774cfb89beb191122852653 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 14 Jul 2026 15:55:27 +0200 Subject: [PATCH 595/707] Make NS device a service (#176424) --- .../components/nederlandse_spoorwegen/binary_sensor.py | 3 ++- homeassistant/components/nederlandse_spoorwegen/sensor.py | 3 ++- .../nederlandse_spoorwegen/snapshots/test_init.ambr | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py index 7061bdc83f60..6d6b2a7c1e98 100644 --- a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -109,6 +109,7 @@ class NSBinarySensor(CoordinatorEntity[NSDataUpdateCoordinator], BinarySensorEnt name=coordinator.name, manufacturer=INTEGRATION_TITLE, model=ROUTE_MODEL, + entry_type=DeviceEntryType.SERVICE, ) @property diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index c88ef824aa11..7eef35544195 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -202,6 +202,7 @@ class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity): name=coordinator.name, manufacturer=INTEGRATION_TITLE, model=ROUTE_MODEL, + entry_type=DeviceEntryType.SERVICE, ) @property diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr index 96b3def82e77..f37f79b384bb 100644 --- a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr @@ -9,7 +9,7 @@ 'connections': set({ }), 'disabled_by': None, - 'entry_type': None, + 'entry_type': , 'hw_version': None, 'id': , 'identifiers': set({ @@ -38,7 +38,7 @@ 'connections': set({ }), 'disabled_by': None, - 'entry_type': None, + 'entry_type': , 'hw_version': None, 'id': , 'identifiers': set({ From 67e22ba3b25d97da57d7acf5ee56913aed97c931 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 14 Jul 2026 16:43:48 +0200 Subject: [PATCH 596/707] Set up network integration independently of http (#176137) Co-authored-by: Claude Fable 5 --- homeassistant/components/network/manifest.json | 1 - tests/components/network/test_init.py | 2 +- tests/components/ssdp/test_init.py | 5 +++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json index a76da88914d6..7dee6332f583 100644 --- a/homeassistant/components/network/manifest.json +++ b/homeassistant/components/network/manifest.json @@ -2,7 +2,6 @@ "domain": "network", "name": "Network Configuration", "codeowners": ["@home-assistant/core"], - "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/network", "integration_type": "system", "iot_class": "local_push", diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index d54a4e2b5e68..6309eaa183c3 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -608,7 +608,7 @@ async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses "homeassistant.components.network.util.ifaddr.get_adapters", return_value=[], ): - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() with pytest.raises(HomeAssistantError): await network.async_get_source_ip(hass, MDNS_TARGET_IP) diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 3e1d1322e7dc..25dac8d3b058 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -763,6 +763,11 @@ async def test_bind_failure_skips_adapter( if self.source == ("2001:db8::", 0, 0, 1): raise OSError + # The UPnP server needs a presentation URL, which is derived from the + # instance URL. In production http is set up before ssdp; set an internal + # URL here so get_url() succeeds without relying on http being set up. + hass.config.internal_url = "http://10.10.10.10:8123" + SsdpListener.async_start = _async_start UpnpServer.async_start = _async_start await init_ssdp_component(hass) From 9d8123ef2537d4072c3bbd06e87ebf8d464962b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 17:03:55 +0200 Subject: [PATCH 597/707] Add dynamic device discovery to nobo_hub (#176441) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nobo_hub/climate.py | 18 +++++++--- .../components/nobo_hub/quality_scale.yaml | 2 +- homeassistant/components/nobo_hub/select.py | 23 +++++++++---- homeassistant/components/nobo_hub/sensor.py | 25 ++++++++++---- tests/components/nobo_hub/__init__.py | 9 +++++ tests/components/nobo_hub/test_climate.py | 27 ++++++++++++++- tests/components/nobo_hub/test_select.py | 26 ++++++++++++++- tests/components/nobo_hub/test_sensor.py | 33 ++++++++++++++++++- 8 files changed, 141 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 2ddd05e1bbd3..06552658b0ec 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -56,8 +56,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nobø Ecohub platform from UI configuration.""" - - # Setup connection with hub hub = config_entry.runtime_data override_type = ( @@ -66,8 +64,20 @@ async def async_setup_entry( else nobo.API.OVERRIDE_TYPE_CONSTANT ) - # Add zones as entities - async_add_entities(NoboZone(zone_id, hub, override_type) for zone_id in hub.zones) + known_zones: set[str] = set() + + @callback + def _add_zones(_hub: nobo) -> None: + """Add climate entities for zones added to the hub.""" + new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] + known_zones.update(new_zones) + async_add_entities( + NoboZone(zone_id, hub, override_type) for zone_id in new_zones + ) + + _add_zones(hub) + hub.register_callback(_add_zones) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_zones)) class NoboZone(NoboBaseEntity, ClimateEntity): diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index aa91d4b5a5ac..ce62526480a9 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -58,7 +58,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: todo entity-device-class: done entity-disabled-by-default: todo diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 9c8313ebdfc5..40b85798d42f 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -32,8 +32,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" - - # Setup connection with hub hub = config_entry.runtime_data override_type = ( @@ -42,11 +40,22 @@ async def async_setup_entry( else nobo.API.OVERRIDE_TYPE_CONSTANT ) - entities: list[SelectEntity] = [ - NoboProfileSelector(zone_id, hub) for zone_id in hub.zones - ] - entities.append(NoboGlobalSelector(hub, override_type)) - async_add_entities(entities, True) + async_add_entities([NoboGlobalSelector(hub, override_type)], True) + + known_zones: set[str] = set() + + @callback + def _add_profiles(_hub: nobo) -> None: + """Add week-profile selectors for zones added to the hub.""" + new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] + known_zones.update(new_zones) + async_add_entities( + (NoboProfileSelector(zone_id, hub) for zone_id in new_zones), True + ) + + _add_profiles(hub) + hub.register_callback(_add_profiles) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_profiles)) class NoboGlobalSelector(NoboBaseEntity, SelectEntity): diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 8ebea0b63419..88bc76bf1506 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -28,15 +28,26 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" - - # Setup connection with hub hub = config_entry.runtime_data - async_add_entities( - NoboTemperatureSensor(component["serial"], hub) - for component in hub.components.values() - if component[ATTR_MODEL].has_temp_sensor - ) + known_components: set[str] = set() + + @callback + def _add_sensors(_hub: nobo) -> None: + """Add temperature sensors for components added to the hub.""" + new_components = [ + serial + for serial, component in hub.components.items() + if component[ATTR_MODEL].has_temp_sensor and serial not in known_components + ] + known_components.update(new_components) + async_add_entities( + NoboTemperatureSensor(serial, hub) for serial in new_components + ) + + _add_sensors(hub) + hub.register_callback(_add_sensors) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_sensors)) class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index d487b000d04d..48e57be118be 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -3,6 +3,15 @@ from unittest.mock import MagicMock from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +def entity_unique_ids(entity_registry: er.EntityRegistry, entry_id: str) -> set[str]: + """Return the unique ids of all entities for the config entry.""" + return { + entry.unique_id + for entry in er.async_entries_for_config_entry(entity_registry, entry_id) + } async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py index 2e42c5934285..2ea1baf77b3f 100644 --- a/tests/components/nobo_hub/test_climate.py +++ b/tests/components/nobo_hub/test_climate.py @@ -32,11 +32,19 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import fire_hub_update +from . import entity_unique_ids, fire_hub_update +from .conftest import SERIAL from tests.common import MockConfigEntry, snapshot_platform CLIMATE_ENTITY = "climate.living_room_living_room" +BEDROOM_ZONE = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", +} @pytest.fixture @@ -264,3 +272,20 @@ async def test_climate_action_wraps_library_error( ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == expected_key + + +@pytest.mark.usefixtures("init_integration") +async def test_new_zone_adds_entity( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone added on the hub at runtime creates a climate entity.""" + entry_id = mock_config_entry.entry_id + assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_select.py b/tests/components/nobo_hub/test_select.py index 3c6871254655..e9a6eaa7c58f 100644 --- a/tests/components/nobo_hub/test_select.py +++ b/tests/components/nobo_hub/test_select.py @@ -17,7 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import fire_hub_update +from . import entity_unique_ids, fire_hub_update +from .conftest import SERIAL from tests.common import MockConfigEntry, snapshot_platform @@ -161,3 +162,26 @@ async def test_zone_removed_marks_week_profile_unavailable( mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) assert hass.states.get(PROFILE_ENTITY).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_new_zone_adds_profile_selector( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone added on the hub at runtime creates a week-profile selector.""" + entry_id = mock_config_entry.entry_id + assert f"{SERIAL}:2:profile" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + await fire_hub_update(hass, mock_nobo_hub) + + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_sensor.py b/tests/components/nobo_hub/test_sensor.py index 340a06117561..8a8c3e3404ba 100644 --- a/tests/components/nobo_hub/test_sensor.py +++ b/tests/components/nobo_hub/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import fire_hub_update +from . import entity_unique_ids, fire_hub_update from tests.common import MockConfigEntry, snapshot_platform @@ -66,3 +66,34 @@ async def test_component_removed_marks_unavailable( mock_nobo_hub.components.pop("200000059091") await fire_hub_update(hass, mock_nobo_hub) assert hass.states.get(TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("has_temp_sensor", "present"), + [(True, True), (False, False)], + ids=["temp_sensor", "no_temp_sensor"], +) +@pytest.mark.usefixtures("init_integration") +async def test_new_component_added( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + has_temp_sensor: bool, + present: bool, +) -> None: + """A component yields a sensor only when it has a temperature sensor.""" + entry_id = mock_config_entry.entry_id + serial = "200000059092" + model = MagicMock() + model.name = "Panel heater" + model.has_temp_sensor = has_temp_sensor + mock_nobo_hub.components[serial] = { + "serial": serial, + "name": "Bedroom sensor", + "zone_id": "1", + "model": model, + } + await fire_hub_update(hass, mock_nobo_hub) + + assert (serial in entity_unique_ids(entity_registry, entry_id)) is present From d66819ca72e4b0ca24199c7abd50a2c1e7c5bcfb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:20:06 +0200 Subject: [PATCH 598/707] Use entity state attribute enums in GPSLogger (#176466) --- .../components/gpslogger/device_tracker.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index c8dd60ba98f2..32e591e099cd 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -2,13 +2,11 @@ from typing import override -from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, ) +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -120,9 +118,11 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) - self._attr_location_accuracy = attr.get(ATTR_GPS_ACCURACY, 0) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) + self._attr_location_accuracy = attr.get( + TrackerEntityStateAttribute.GPS_ACCURACY, 0 + ) self._attr_extra_state_attributes = { ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY), From aaedbe433e4686e20d37f9a449c7f336a8817f6e Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 14 Jul 2026 17:22:03 +0200 Subject: [PATCH 599/707] Add diagnostics platform to LED Infrared (#176459) --- .../components/led_infrared/diagnostics.py | 14 +++++++++ .../led_infrared/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 7 +++++ .../led_infrared/test_diagnostics.py | 30 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/led_infrared/diagnostics.py create mode 100644 tests/components/led_infrared/snapshots/test_diagnostics.ambr create mode 100644 tests/components/led_infrared/test_diagnostics.py diff --git a/homeassistant/components/led_infrared/diagnostics.py b/homeassistant/components/led_infrared/diagnostics.py new file mode 100644 index 000000000000..cd74ce1614ee --- /dev/null +++ b/homeassistant/components/led_infrared/diagnostics.py @@ -0,0 +1,14 @@ +"""Diagnostics platform for the LED Infrared integration.""" + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return dict(config_entry.data) diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml index a1fe453f6102..5119557d203f 100644 --- a/homeassistant/components/led_infrared/quality_scale.yaml +++ b/homeassistant/components/led_infrared/quality_scale.yaml @@ -65,7 +65,7 @@ rules: test-coverage: todo # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/led_infrared/snapshots/test_diagnostics.ambr b/tests/components/led_infrared/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..f137fce72065 --- /dev/null +++ b/tests/components/led_infrared/snapshots/test_diagnostics.ambr @@ -0,0 +1,7 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device_type': 'generic_24_key', + 'infrared_entity_id': 'infrared.test_ir_emitter', + }) +# --- diff --git a/tests/components/led_infrared/test_diagnostics.py b/tests/components/led_infrared/test_diagnostics.py new file mode 100644 index 000000000000..16f913428770 --- /dev/null +++ b/tests/components/led_infrared/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test for diagnostics platform of the LED Infrared integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 25026554bd083e85ea8ba340ef7b5c08acf8eba7 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Tue, 14 Jul 2026 17:36:53 +0200 Subject: [PATCH 600/707] Bump PyViCare to 2.61.0 (#176499) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/vicare/conftest.py | 29 +++++++++++-------- .../vicare/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 55ba55642566..78e66edf31af 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.60.2"] + "requirements": ["PyViCare==2.61.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f8d4e733c57..aa4cf64c0ab9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -99,7 +99,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.60.2 +PyViCare==2.61.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 90dc29f25533..4cb265422f3a 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -38,33 +38,37 @@ class MockPyViCare: """Init a single device from json dump.""" self.devices = [] for idx, fixture in enumerate(fixtures): + service = MockViCareService( + f"installation{idx}", f"gateway{idx}", f"deviceId{idx}", fixture + ) self.devices.append( PyViCareDeviceConfig( - MockViCareService( - f"installation{idx}", f"gateway{idx}", f"device{idx}", fixture - ), - f"deviceId{idx}", + service.accessor, + service, "Vitovalor" if fixture.data_file.endswith("VitoValor.json") else f"model{idx}", "Online", + roles=list(fixture.roles), ) ) # Simulate a device with an unsupported deviceType that PyViCare's # `devices` filter would drop but should still appear in `all_devices` # (used by diagnostics). + unsupported_service = MockViCareService( + "installation_unsupported", + "gateway_unsupported", + "deviceId_unsupported", + Fixture(set(), "vicare/dummy-device-no-serial.json"), + ) self.all_devices = [ *self.devices, PyViCareDeviceConfig( - MockViCareService( - "installation_unsupported", - "gateway_unsupported", - "device_unsupported", - Fixture(set(), "vicare/dummy-device-no-serial.json"), - ), - "deviceId_unsupported", + unsupported_service.accessor, + unsupported_service, "unsupported_model", "Online", + roles=[], ), ] @@ -88,6 +92,7 @@ class MockViCareService: """Initialize the mock from a json dump.""" self._test_data = load_json_object_fixture(fixture.data_file) self.fetch_all_features = Mock(return_value=self._test_data) + self.setProperty = Mock() self.roles = fixture.roles self.accessor = ViCareDeviceAccessor(installation_id, gateway_id, device_id) @@ -95,7 +100,7 @@ class MockViCareService: """Return true if requested roles are assigned.""" return requested_roles and set(requested_roles).issubset(self.roles) - def getProperty(self, property_name: str): + def getProperty(self, accessor: ViCareDeviceAccessor, property_name: str): """Read a property from json dump.""" return readFeature(self._test_data["data"], property_name) diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index fb27ef68d284..4f189f5bc56b 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4715,6 +4715,7 @@ 'id': 'deviceId0', 'modelId': 'model0', 'roles': list([ + 'type:boiler', ]), 'status': 'Online', 'type': None, From 445643e90473f450792a2139228fa80d5d0c1325 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Tue, 14 Jul 2026 17:46:03 +0200 Subject: [PATCH 601/707] Fix untranslated BleBox button and input binary sensor names (#176497) --- .../components/blebox/binary_sensor.py | 1 + homeassistant/components/blebox/button.py | 2 -- homeassistant/components/blebox/strings.json | 10 +++++++ tests/components/blebox/test_binary_sensor.py | 4 +-- tests/components/blebox/test_button.py | 30 +++++++++---------- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index ba7c768f24aa..aca1b550eada 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -29,6 +29,7 @@ BINARY_SENSOR_TYPES = ( ), BinarySensorEntityDescription( key="input", + translation_key="input", ), ) diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index fd277810369f..16ab7b4493d7 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -43,8 +43,6 @@ async def async_setup_entry( class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity): """Representation of BleBox buttons.""" - _attr_name = None - def __init__( self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button ) -> None: diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index 382d6c34ebe0..82f9cb4944f6 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -70,6 +70,16 @@ } }, "entity": { + "binary_sensor": { + "input": { "name": "Input" } + }, + "button": { + "close": { "name": "Close" }, + "down": { "name": "Down" }, + "fav": { "name": "Favorite" }, + "open": { "name": "Open" }, + "up": { "name": "Up" } + }, "light": { "channel": { "name": "Channel {index}" } }, "sensor": { "active_power": { "name": "Active power" }, diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index ea9585f0a746..1ba01a7ef02a 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -62,7 +62,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: product = feature.product type(product).name = PropertyMock(return_value="My input sensor") type(product).model = PropertyMock(return_value="inputSensorD") - return feature, "binary_sensor.my_input_sensor" + return feature, "binary_sensor.my_input_sensor_input" @pytest.mark.parametrize( @@ -87,7 +87,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: pytest.param( "inputsensor", "BleBox-inputSensorD-aa11bb22cc33-0.input", - "My input sensor", + "My input sensor Input", None, STATE_ON, "My input sensor", diff --git a/tests/components/blebox/test_button.py b/tests/components/blebox/test_button.py index 6e9a5c3323bb..1ec63623141b 100644 --- a/tests/components/blebox/test_button.py +++ b/tests/components/blebox/test_button.py @@ -7,17 +7,16 @@ import blebox_uniapi import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .conftest import async_setup_entity, mock_feature query_translation_key_matching = [ - ("up", "up"), - ("down", "down"), - ("fav", "fav"), - ("open", "open"), - ("close", "close"), - ("unknown_action", None), + ("up", "up", "button.my_tvliftbox_up", "My tvLiftBox Up"), + ("down", "down", "button.my_tvliftbox_down", "My tvLiftBox Down"), + ("fav", "fav", "button.my_tvliftbox_favorite", "My tvLiftBox Favorite"), + ("open", "open", "button.my_tvliftbox_open", "My tvLiftBox Open"), + ("close", "close", "button.my_tvliftbox_close", "My tvLiftBox Close"), + ("unknown_action", None, "button.my_tvliftbox", "My tvLiftBox"), ] @@ -58,13 +57,15 @@ async def test_tvliftbox_init( @pytest.mark.parametrize( - ("query_string", "expected_translation_key"), + ("query_string", "expected_translation_key", "expected_entity_id", "expected_name"), query_translation_key_matching, ids=[q[0] for q in query_translation_key_matching], ) async def test_button_translation_key( query_string: str, expected_translation_key: str | None, + expected_entity_id: str, + expected_name: str, tvliftbox: tuple[blebox_uniapi.button.Button, str], hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -72,13 +73,12 @@ async def test_button_translation_key( """Test that the correct translation_key is assigned based on query_string.""" caplog.set_level(logging.ERROR) - feature_mock, entity_id = tvliftbox + feature_mock, _ = tvliftbox feature_mock.query_string = query_string - await async_setup_entity(hass, entity_id) - - state = hass.states.get(entity_id) - assert state is not None - - entity = er.async_get(hass).async_get(entity_id) + entity = await async_setup_entity(hass, expected_entity_id) assert entity is not None assert entity.translation_key == expected_translation_key + + state = hass.states.get(expected_entity_id) + assert state is not None + assert state.name == expected_name From 5b8c8578e430aa61ccdb389b1488acf3820682f3 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:46:20 +0200 Subject: [PATCH 602/707] Update syrupy to 5.5.1 (#176489) --- requirements_test.txt | 2 +- tests/conftest.py | 8 +- tests/syrupy.py | 169 ------------------------------------------ 3 files changed, 2 insertions(+), 177 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 8873e7986966..d6cabd86c687 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.3.4 +syrupy==5.5.1 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 diff --git a/tests/conftest.py b/tests/conftest.py index 5fba335c33a7..f8e7e37bc53c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ import pytest_socket import requests_mock import respx from syrupy.assertion import SnapshotAssertion -from syrupy.session import SnapshotSession # Setup patching of JSON functions before any other Home Assistant imports from . import patch_json # isort:skip @@ -108,7 +107,7 @@ from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_han from homeassistant.util.json import json_loads from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS -from .syrupy import HomeAssistantSnapshotExtension, override_syrupy_finish +from .syrupy import HomeAssistantSnapshotExtension from .typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -173,11 +172,6 @@ def pytest_configure(config: pytest.Config) -> None: if config.getoption("verbose") > 0: logging.getLogger().setLevel(logging.DEBUG) - # Override default finish to detect unused snapshots despite xdist - # Temporary workaround until it is finalised inside syrupy - # See https://github.com/syrupy-project/syrupy/pull/901 - SnapshotSession.finish = override_syrupy_finish - class HASocketBlockedError(pytest_socket.SocketBlockedError): """SocketBlockedError variant which counts instances.""" diff --git a/tests/syrupy.py b/tests/syrupy.py index a87799631098..253ebea3f247 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -3,22 +3,14 @@ from contextlib import suppress import dataclasses from enum import IntFlag -import json -import os from pathlib import Path from typing import Any import attr import attrs -import pytest -from syrupy.constants import EXIT_STATUS_FAIL_UNUSED -from syrupy.data import Snapshot, SnapshotCollection, SnapshotCollections from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension from syrupy.location import PyTestLocation -from syrupy.report import SnapshotReport -from syrupy.session import ItemStatus, SnapshotSession from syrupy.types import PropertyFilter, PropertyMatcher, PropertyPath, SerializableData -from syrupy.utils import is_xdist_controller, is_xdist_worker import voluptuous as vol import voluptuous_serialize @@ -272,164 +264,3 @@ class HomeAssistantSnapshotExtension(AmberSnapshotExtension): """ test_dir = Path(test_location.filepath).parent return str(test_dir.joinpath("snapshots")) - - -# Classes and Methods to override default finish behavior in syrupy -# This is needed to handle the xdist plugin in pytest -# The default implementation does not handle the xdist plugin -# and will not work correctly when running tests in parallel -# with pytest-xdist. -# Temporary workaround until it is finalised inside syrupy -# See https://github.com/syrupy-project/syrupy/pull/901 - - -class _FakePytestObject: - """Fake object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake object.""" - self.__module__ = collected_item["modulename"] - self.__name__ = collected_item["methodname"] - - -class _FakePytestItem: - """Fake pytest.Item object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake pytest.Item object.""" - self.nodeid = collected_item["nodeid"] - self.name = collected_item["name"] - self.path = Path(collected_item["path"]) - self.obj = _FakePytestObject(collected_item) - - -def _serialize_collections(collections: SnapshotCollections) -> dict[str, Any]: - return { - k: [c.name for c in v] for k, v in collections._snapshot_collections.items() - } - - -def _serialize_report( - report: SnapshotReport, - collected_items: set[pytest.Item], - selected_items: dict[str, ItemStatus], -) -> dict[str, Any]: - return { - "discovered": _serialize_collections(report.discovered), - "created": _serialize_collections(report.created), - "failed": _serialize_collections(report.failed), - "matched": _serialize_collections(report.matched), - "updated": _serialize_collections(report.updated), - "used": _serialize_collections(report.used), - "_collected_items": [ - { - "nodeid": c.nodeid, - "name": c.name, - "path": str(c.path), - "modulename": c.obj.__module__, - "methodname": c.obj.__name__, - } - for c in list(collected_items) - ], - "_selected_items": { - key: status.value for key, status in selected_items.items() - }, - } - - -def _merge_serialized_collections( - collections: SnapshotCollections, json_data: dict[str, list[str]] -) -> None: - if not json_data: - return - for location, names in json_data.items(): - snapshot_collection = SnapshotCollection(location=location) - for name in names: - snapshot_collection.add(Snapshot(name)) - collections.update(snapshot_collection) - - -def _merge_serialized_report(report: SnapshotReport, json_data: dict[str, Any]) -> None: - _merge_serialized_collections(report.discovered, json_data["discovered"]) - _merge_serialized_collections(report.created, json_data["created"]) - _merge_serialized_collections(report.failed, json_data["failed"]) - _merge_serialized_collections(report.matched, json_data["matched"]) - _merge_serialized_collections(report.updated, json_data["updated"]) - _merge_serialized_collections(report.used, json_data["used"]) - for collected_item in json_data["_collected_items"]: - custom_item = _FakePytestItem(collected_item) - if not any( - t.nodeid == custom_item.nodeid and t.name == custom_item.nodeid - for t in report.collected_items - ): - report.collected_items.add(custom_item) - for key, selected_item in json_data["_selected_items"].items(): - if key in report.selected_items: - status = ItemStatus(selected_item) - if status is not ItemStatus.NOT_RUN: - report.selected_items[key] = status - else: - report.selected_items[key] = ItemStatus(selected_item) - - -def override_syrupy_finish(self: SnapshotSession) -> int: - """Override the finish method to allow for custom handling.""" - exitstatus = 0 - self.flush_snapshot_write_queue() - self.report = SnapshotReport( - base_dir=self.pytest_session.config.rootpath, - collected_items=self._collected_items, - selected_items=self._selected_items, - assertions=self._assertions, - options=self.pytest_session.config.option, - ) - - needs_xdist_merge = self.update_snapshots or bool( - self.pytest_session.config.option.include_snapshot_details - ) - - if is_xdist_worker(): - if not needs_xdist_merge: - return exitstatus - with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: - f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) - with open( - f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", - "w", - encoding="utf-8", - ) as f: - json.dump( - _serialize_report( - self.report, self._collected_items, self._selected_items - ), - f, - indent=2, - ) - return exitstatus - if is_xdist_controller(): - return exitstatus - - if needs_xdist_merge: - worker_count = None - try: - with open(".pytest_syrupy_worker_count", encoding="utf-8") as f: - worker_count = f.read() - os.remove(".pytest_syrupy_worker_count") - except FileNotFoundError: - pass - - if worker_count: - for i in range(int(worker_count)): - with open(f".pytest_syrupy_gw{i}_result", encoding="utf-8") as f: - _merge_serialized_report(self.report, json.load(f)) - os.remove(f".pytest_syrupy_gw{i}_result") - - if self.report.num_unused: - if self.update_snapshots: - self.remove_unused_snapshots( - unused_snapshot_collections=self.report.unused, - used_snapshot_collections=self.report.used, - ) - elif not self.warn_unused_snapshots: - exitstatus |= EXIT_STATUS_FAIL_UNUSED - return exitstatus From 46f82fd3ca39069c992b580a771e67e70e6b7679 Mon Sep 17 00:00:00 2001 From: Hamish Date: Wed, 15 Jul 2026 01:16:29 +0930 Subject: [PATCH 603/707] Add Gatus Integration (#175085) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/gatus/__init__.py | 25 +++ .../components/gatus/binary_sensor.py | 105 +++++++++++ homeassistant/components/gatus/config_flow.py | 87 ++++++++++ homeassistant/components/gatus/const.py | 3 + homeassistant/components/gatus/coordinator.py | 48 +++++ homeassistant/components/gatus/manifest.json | 12 ++ .../components/gatus/quality_scale.yaml | 88 ++++++++++ homeassistant/components/gatus/strings.json | 28 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + tests/components/gatus/__init__.py | 15 ++ tests/components/gatus/conftest.py | 58 +++++++ tests/components/gatus/fixtures/group.json | 8 + tests/components/gatus/fixtures/no_group.json | 7 + .../gatus/snapshots/test_binary_sensor.ambr | 52 ++++++ tests/components/gatus/test_binary_sensor.py | 164 ++++++++++++++++++ tests/components/gatus/test_config_flow.py | 154 ++++++++++++++++ tests/components/gatus/test_init.py | 46 +++++ 22 files changed, 923 insertions(+) create mode 100644 homeassistant/components/gatus/__init__.py create mode 100644 homeassistant/components/gatus/binary_sensor.py create mode 100644 homeassistant/components/gatus/config_flow.py create mode 100644 homeassistant/components/gatus/const.py create mode 100644 homeassistant/components/gatus/coordinator.py create mode 100644 homeassistant/components/gatus/manifest.json create mode 100644 homeassistant/components/gatus/quality_scale.yaml create mode 100644 homeassistant/components/gatus/strings.json create mode 100644 tests/components/gatus/__init__.py create mode 100644 tests/components/gatus/conftest.py create mode 100644 tests/components/gatus/fixtures/group.json create mode 100644 tests/components/gatus/fixtures/no_group.json create mode 100644 tests/components/gatus/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/gatus/test_binary_sensor.py create mode 100644 tests/components/gatus/test_config_flow.py create mode 100644 tests/components/gatus/test_init.py diff --git a/.strict-typing b/.strict-typing index e3629e702389..400f8d1f32ed 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.* homeassistant.components.fully_kiosk.* homeassistant.components.fumis.* homeassistant.components.fyta.* +homeassistant.components.gatus.* homeassistant.components.generic_hygrostat.* homeassistant.components.generic_thermostat.* homeassistant.components.geo_location.* diff --git a/CODEOWNERS b/CODEOWNERS index 8d02a119003d..ccb837bedb74 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,6 +625,8 @@ CLAUDE.md @home-assistant/core /tests/components/gardena_bluetooth/ @elupus /homeassistant/components/gate/ @home-assistant/core /tests/components/gate/ @home-assistant/core +/homeassistant/components/gatus/ @TN-1 +/tests/components/gatus/ @TN-1 /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 diff --git a/homeassistant/components/gatus/__init__.py b/homeassistant/components/gatus/__init__.py new file mode 100644 index 000000000000..93cbcfc5999a --- /dev/null +++ b/homeassistant/components/gatus/__init__.py @@ -0,0 +1,25 @@ +"""The Gatus integration.""" + +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Set up Gatus from a config entry.""" + coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL]) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/gatus/binary_sensor.py b/homeassistant/components/gatus/binary_sensor.py new file mode 100644 index 000000000000..f35d8815e42d --- /dev/null +++ b/homeassistant/components/gatus/binary_sensor.py @@ -0,0 +1,105 @@ +"""Support for Gatus binary sensors.""" + +from typing import override + +from gatus_api import EndpointStatus, Result + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GatusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Gatus binary sensor platform.""" + coordinator = entry.runtime_data + + async_add_entities( + GatusEndpointBinarySensor(coordinator, entry, endpoint_key) + for endpoint_key in coordinator.data + ) + + +class GatusEndpointBinarySensor( + CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Gatus endpoint status.""" + + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: GatusDataUpdateCoordinator, + entry: GatusConfigEntry, + endpoint_key: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._endpoint_key = endpoint_key + + endpoint_data = self.endpoint_data + + endpoint_name = endpoint_data.name + if endpoint_data.group is not None: + device_name = f"{endpoint_data.group} {endpoint_name}" + else: + device_name = endpoint_name + + self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")}, + name=device_name, + manufacturer="Gatus", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + @override + def is_on(self) -> bool | None: + """Return true if the endpoint is up and healthy.""" + latest_result = self.latest_result + if latest_result is None: + return None + + return latest_result.success + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + data = self.coordinator.data + # Guard for empty results list, which could imply a brand new endpoint + return ( + super().available + and self._endpoint_key in data + and bool(data[self._endpoint_key].results) + ) + + @property + def endpoint_data(self) -> EndpointStatus: + """Return this specific endpoint's data from the coordinator.""" + return self.coordinator.data[self._endpoint_key] + + @property + def latest_result(self) -> Result | None: + """Return the most recent monitoring result (Gatus appends newest last).""" + results = self.endpoint_data.results + if not results: + return None + return results[-1] diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py new file mode 100644 index 000000000000..972f200abae7 --- /dev/null +++ b/homeassistant/components/gatus/config_flow.py @@ -0,0 +1,87 @@ +"""Config flow for the Gatus integration.""" + +import logging +from typing import Any, override + +from gatus_api import GatusClient, GatusClientError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate that the user input allows us to connect to Gatus and return data.""" + client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass)) + + try: + await client.get_endpoints_statuses() + except GatusClientError as err: + _LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err) + raise CannotConnect from err + + +class GatusConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Gatus.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial setup step when adding the integration via the UI.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + url = URL(user_input[CONF_URL]) + except ValueError: + errors["base"] = "invalid_url" + else: + if url.scheme not in {"http", "https"} or not url.host: + errors["base"] = "invalid_url" + else: + normalized_url = str( + url.with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + user_input[CONF_URL] = normalized_url + + self._async_abort_entries_match({CONF_URL: normalized_url}) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus setup") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Gatus", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the server.""" diff --git a/homeassistant/components/gatus/const.py b/homeassistant/components/gatus/const.py new file mode 100644 index 000000000000..89ac9ee41fff --- /dev/null +++ b/homeassistant/components/gatus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Gatus integration.""" + +DOMAIN = "gatus" diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py new file mode 100644 index 000000000000..37739f2ff6f3 --- /dev/null +++ b/homeassistant/components/gatus/coordinator.py @@ -0,0 +1,48 @@ +"""DataUpdateCoordinator for the Gatus integration.""" + +from datetime import timedelta +import logging +from typing import override + +from gatus_api import EndpointStatus, GatusClient, GatusClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type GatusConfigEntry = ConfigEntry[GatusDataUpdateCoordinator] + + +class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]]): + """Class to manage fetching Gatus data from the API via third-party library.""" + + def __init__(self, hass: HomeAssistant, entry: GatusConfigEntry, url: str) -> None: + """Initialize the coordinator.""" + self.url = url.rstrip("/") + self.client = GatusClient(url=self.url, session=async_get_clientsession(hass)) + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @override + async def _async_update_data(self) -> dict[str, EndpointStatus]: + """Fetch endpoint statuses from the Gatus API.""" + try: + raw_endpoints = await self.client.get_endpoints_statuses() + except GatusClientError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from err + + return {ep.key: ep for ep in raw_endpoints} diff --git a/homeassistant/components/gatus/manifest.json b/homeassistant/components/gatus/manifest.json new file mode 100644 index 000000000000..53fddeab56ed --- /dev/null +++ b/homeassistant/components/gatus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gatus", + "name": "Gatus", + "codeowners": ["@TN-1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gatus", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["gatus_api"], + "quality_scale": "silver", + "requirements": ["gatus-api==1.0.3"] +} diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml new file mode 100644 index 000000000000..3d9207ece6b9 --- /dev/null +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: + status: exempt + comment: Integration does not register custom events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration does not use authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Integration does not support discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities represent monitored services and should be enabled by default. + entity-translations: + status: exempt + comment: Entity names are dynamically provided by the Gatus service. + exception-translations: done + icon-translations: + status: exempt + comment: Entities use the connectivity device class for their icon and define no custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not require user intervention repairs. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json new file mode 100644 index 000000000000..6f6610ddbb01 --- /dev/null +++ b/homeassistant/components/gatus/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_url": "Please enter a valid absolute URL (e.g., http://192.168.1.50:8080)", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The full base URL of your Gatus status page instance including protocol and port." + }, + "description": "Enter the network details for your Gatus status page instance. Make sure to include the protocol (e.g., `http://` or `https://`) and the port number if you are not using a standard port." + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error communicating with Gatus API" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 83f559cf2811..da9a9ef06b1b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,6 +264,7 @@ FLOWS = { "fyta", "garages_amsterdam", "gardena_bluetooth", + "gatus", "gdacs", "generic", "geniushub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2387a9de906f..b676f78a5c2a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2388,6 +2388,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "gatus": { + "name": "Gatus", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "gaviota": { "name": "Gaviota", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 73645a4a2360..2da3ccca92c7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2037,6 +2037,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gatus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.generic_hygrostat.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index aa4cf64c0ab9..ab54a315cbee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1077,6 +1077,9 @@ gardena-bluetooth==2.8.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 +# homeassistant.components.gatus +gatus-api==1.0.3 + # homeassistant.components.google gcal-sync==8.0.0 diff --git a/tests/components/gatus/__init__.py b/tests/components/gatus/__init__.py new file mode 100644 index 000000000000..26e3d9d4d9b1 --- /dev/null +++ b/tests/components/gatus/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Gatus integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Gatus integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/gatus/conftest.py b/tests/components/gatus/conftest.py new file mode 100644 index 000000000000..1e557575e259 --- /dev/null +++ b/tests/components/gatus/conftest.py @@ -0,0 +1,58 @@ +"""Common fixtures for the Gatus tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from gatus_api import EndpointStatus, Result +import pytest + +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gatus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_gatus_client() -> Generator[AsyncMock]: + """Mock the third-party Gatus API client wrapper globally across coordinator and config flow.""" + with ( + patch( + "homeassistant.components.gatus.coordinator.GatusClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.gatus.config_flow.GatusClient", + new=mock_client, + ), + ): + client_instance = mock_client.return_value + client_instance.get_endpoints_statuses = AsyncMock( + return_value=[ + EndpointStatus( + key="backend_service", + name="Backend Service", + group="Core", + results=[Result(success=True, status=200)], + ) + ] + ) + yield client_instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Fixture to cleanly create a Gatus configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example.com:8080"}, + entry_id="1234567890abcdef1234567890abcdef", + ) diff --git a/tests/components/gatus/fixtures/group.json b/tests/components/gatus/fixtures/group.json new file mode 100644 index 000000000000..8c7c032441e2 --- /dev/null +++ b/tests/components/gatus/fixtures/group.json @@ -0,0 +1,8 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "group": "Core", + "results": [{ "success": false, "status": 500 }] + } +] diff --git a/tests/components/gatus/fixtures/no_group.json b/tests/components/gatus/fixtures/no_group.json new file mode 100644 index 000000000000..c582a4eb7535 --- /dev/null +++ b/tests/components/gatus/fixtures/no_group.json @@ -0,0 +1,7 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "results": [{ "success": true, "status": 200 }] + } +] diff --git a/tests/components/gatus/snapshots/test_binary_sensor.ambr b/tests/components/gatus/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..56d2fc37d30b --- /dev/null +++ b/tests/components/gatus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.core_backend_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'gatus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef_backend_service', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'Core Backend Service', + }), + 'context': , + 'entity_id': 'binary_sensor.core_backend_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/gatus/test_binary_sensor.py b/tests/components/gatus/test_binary_sensor.py new file mode 100644 index 000000000000..761a2c757a0f --- /dev/null +++ b/tests/components/gatus/test_binary_sensor.py @@ -0,0 +1,164 @@ +"""Tests for the Gatus binary sensor platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from gatus_api import EndpointStatus, GatusClientError, Result +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) + + +async def test_binary_sensor_setup_and_states( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +def _to_endpoint_statuses(raw_data: list[dict[str, Any]]) -> list[EndpointStatus]: + return [ + EndpointStatus( + key=ep["key"], + name=ep["name"], + group=ep.get("group"), + results=[ + Result(success=r["success"], status=r["status"]) + for r in ep.get("results", []) + ], + ) + for ep in raw_data + ] + + +async def test_binary_sensor_dynamic_update( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the binary sensor entity updates when the mock client returns new data.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_data = await async_load_json_array_fixture(hass, "gatus/group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + freezer.tick(300) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "off" + + +async def test_binary_sensor_no_group( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the binary sensor entity is created correctly when an endpoint has no group.""" + mock_data = await async_load_json_array_fixture(hass, "gatus/no_group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "on" + + +async def test_binary_sensor_client_error( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a client exception cleanly marks entities as unavailable.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError + + freezer.tick(30) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "unavailable" + + +async def test_binary_sensor_empty_results( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with empty results is treated as unavailable.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "unavailable" + + # Verify underlying properties return None directly on empty results + entity = hass.data["binary_sensor"].get_entity("binary_sensor.backend_service") + assert entity is not None + assert entity.latest_result is None + assert entity.is_on is None + + +async def test_binary_sensor_missing_status( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with a result missing a status code is handled correctly.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[Result(success=False, status=None)], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "off" diff --git a/tests/components/gatus/test_config_flow.py b/tests/components/gatus/test_config_flow.py new file mode 100644 index 000000000000..45fbc09afe8c --- /dev/null +++ b/tests/components/gatus/test_config_flow.py @@ -0,0 +1,154 @@ +"""Test the Gatus Config flow.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant import config_entries +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form, validate the client, and create a successful entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success_with_path( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form, validate the client, and create a successful entry with a sub-path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080/gatus-instance/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080/gatus-instance", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_gatus_client: AsyncMock +) -> None: + """Test handling of a malformed URL and subsequent recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "gatus.example.com"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:abc"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_form_failures_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test handling validation failures and ensuring the flow can completely recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate configurations for the same base URL abort early.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/gatus/test_init.py b/tests/components/gatus/test_init.py new file mode 100644 index 000000000000..ebb0f2d772c3 --- /dev/null +++ b/tests/components/gatus/test_init.py @@ -0,0 +1,46 @@ +"""Tests for the Gatus integration setup and unload lifecycle.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant.components.gatus.coordinator import GatusDataUpdateCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_setup_and_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test standard successful setup and unload cycle of the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + assert isinstance(mock_config_entry.runtime_data, GatusDataUpdateCoordinator) + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_failure_retry( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an API connection failure during initial setup places the entry in retry state.""" + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError( + "Cannot connect to Gatus API during initial setup" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 1bc5926ff181981e33f64532722a25b27d043c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 14 Jul 2026 17:10:42 +0100 Subject: [PATCH 604/707] Use gh actions service containers instead of direct docker commands (#176502) --- .github/workflows/e2e-tests.yml | 52 ++++++++++++--------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d46a16ed9bd6..b3784dca600a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -9,9 +9,6 @@ on: default: "dev" required: true -env: - STARTUP_TIMEOUT_SECONDS: 300 - permissions: {} concurrency: @@ -33,35 +30,20 @@ jobs: - arch: aarch64 runs-on: ubuntu-24.04-arm env: - IMAGE: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} BASE_URL: http://localhost:8123 CURL_OPTS: --silent --max-time 10 + services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images] + ports: + - 8123:8123 + # Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget) + options: >- + --health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/" + --health-start-period=10s + --health-interval=5s + --health-retries=60 steps: - - name: Pull image - id: pull - run: | - docker pull "$IMAGE" - docker image inspect -f 'Testing {{index .RepoDigests 0}} ({{.Os}}/{{.Architecture}}), created {{.Created}}' "$IMAGE" - - - name: Start container - run: | - docker run -d --name homeassistant -p 8123:8123 "$IMAGE" - - - name: Wait for Home Assistant to start - run: | - timeout=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) - while ! curl $CURL_OPTS --fail --output /dev/null "$BASE_URL/"; do - if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then - echo "::error::Container exited before Home Assistant started" - exit 1 - fi - if [ "$SECONDS" -ge "$timeout" ]; then - echo "::error::Home Assistant did not respond on port 8123 within ${STARTUP_TIMEOUT_SECONDS}s" - exit 1 - fi - sleep 5 - done - - name: Check frontend is served run: | # Pre-onboarding, / redirects to /onboarding.html; --location follows it @@ -77,18 +59,22 @@ jobs: | jq -e 'type == "array" and length > 0' - name: Check container is still running + env: + CONTAINER: ${{ job.services.homeassistant.id }} run: | - if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then + if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then echo "::error::Container is no longer running after checks" exit 1 fi - name: Dump container logs - if: always() && steps.pull.outcome == 'success' - run: docker logs homeassistant > homeassistant.log 2>&1 || true + if: always() + env: + CONTAINER: ${{ job.services.homeassistant.id }} + run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true - name: Upload container logs - if: always() && steps.pull.outcome == 'success' + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: container-logs-${{ matrix.arch }} From de252d4b0db0570c69159fd576d6ae750004476f Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 14 Jul 2026 20:14:37 +0200 Subject: [PATCH 605/707] Fix ProxmoxVE entities allowed without permissions (#176360) --- .../components/proxmoxve/binary_sensor.py | 13 + homeassistant/components/proxmoxve/button.py | 70 +-- homeassistant/components/proxmoxve/const.py | 2 + homeassistant/components/proxmoxve/sensor.py | 12 + .../components/proxmoxve/strings.json | 9 - tests/components/proxmoxve/__init__.py | 7 + tests/components/proxmoxve/conftest.py | 9 +- .../proxmoxve/snapshots/test_button.ambr | 401 ------------------ .../proxmoxve/test_binary_sensor.py | 47 +- tests/components/proxmoxve/test_button.py | 58 +-- tests/components/proxmoxve/test_sensor.py | 25 +- 11 files changed, 141 insertions(+), 512 deletions(-) diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 1dba1d6985ed..69f814a97c11 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -20,6 +20,7 @@ from .const import ( STORAGE_ENABLED, STORAGE_SHARED, VM_CONTAINER_RUNNING, + ProxmoxPermission, ) from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( @@ -28,6 +29,7 @@ from .entity import ( ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -51,6 +53,8 @@ class ProxmoxNodeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class to hold Proxmox node binary sensor description.""" state_fn: Callable[[ProxmoxNodeData], bool | None] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -67,6 +71,8 @@ NODE_SENSORS: tuple[ProxmoxNodeBinarySensorEntityDescription, ...] = ( state_fn=lambda data: data.node["status"] == NODE_ONLINE, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, + permission=ProxmoxPermission.VMAUDIT, # PVEVMUsers are allowed this node, through "/vms" + permission_target="vms", ), ProxmoxNodeBinarySensorEntityDescription( key="node_backup_status", @@ -132,10 +138,17 @@ async def async_setup_entry( def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: """Add new node binary sensors.""" + async_add_entities( ProxmoxNodeBinarySensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 5c5bdda0f114..b93e455dccab 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -17,7 +17,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -28,8 +28,6 @@ from .helpers import is_granted PARALLEL_UPDATES = 1 -NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power" - @dataclass(frozen=True, kw_only=True) class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): @@ -37,7 +35,6 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str], None] permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER - permission_raise: str = "no_permission_node_power" permission_target: str = "nodes" @@ -47,7 +44,6 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -57,7 +53,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -82,7 +77,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="start_all", translation_key="start_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -93,7 +87,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="stop_all", translation_key="stop_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -104,7 +97,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="suspend_all", translation_key="suspend_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -185,7 +177,6 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = ( ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -230,7 +221,6 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = ( ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -250,6 +240,12 @@ async def async_setup_entry( ProxmoxNodeButtonEntity(coordinator, entity_description, node) for node in nodes for entity_description in NODE_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( @@ -260,6 +256,12 @@ async def async_setup_entry( ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data) for (node_data, vm) in vms for entity_description in VM_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=vm["vmid"], + permission=entity_description.permission, + ) ) def _async_add_new_containers( @@ -272,6 +274,12 @@ async def async_setup_entry( ) for (node_data, container) in containers for entity_description in CONTAINER_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=container["vmid"], + permission=entity_description.permission, + ) ) coordinator.new_nodes_callbacks.append(_async_add_new_nodes) @@ -351,21 +359,10 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the node button action via executor.""" - node_id = self._node_data.node["node"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=node_id, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, - node_id, + self._node_data.node["node"], ) @@ -377,22 +374,11 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the VM button action via executor.""" - vmid = self.vm_data["vmid"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.vm_data["vmid"], ) @@ -404,21 +390,9 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the container button action via executor.""" - vmid = self.container_data["vmid"] - # Container power actions fall under vms - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.container_data["vmid"], ) diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index bfd944612a0d..8985a2a77ec9 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -41,4 +41,6 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" + SYSAUDIT = "Sys.Audit" SYSPOWER = "Sys.PowerMgmt" + VMAUDIT = "VM.Audit" diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index 5701473fc8c4..d4140fc13d5e 100644 --- a/homeassistant/components/proxmoxve/sensor.py +++ b/homeassistant/components/proxmoxve/sensor.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from .const import ProxmoxPermission from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( ProxmoxContainerEntity, @@ -25,6 +26,7 @@ from .entity import ( ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -34,6 +36,8 @@ class ProxmoxNodeSensorEntityDescription(SensorEntityDescription): """Class to hold Proxmox node sensor description.""" value_fn: Callable[[ProxmoxNodeData], StateType | datetime] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -147,6 +151,8 @@ NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = ( value_fn=lambda data: data.node["status"], device_class=SensorDeviceClass.ENUM, options=["online", "offline"], + permission=ProxmoxPermission.VMAUDIT, + permission_target="vms", ), ProxmoxNodeSensorEntityDescription( key="node_backup_last_backup", @@ -474,6 +480,12 @@ async def async_setup_entry( ProxmoxNodeSensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index fd35574b8fc2..904b88f894de 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -308,15 +308,6 @@ "no_nodes_found": { "message": "No active nodes were found on the Proxmox VE server." }, - "no_permission_node_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again." - }, - "no_permission_snapshot": { - "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." - }, - "no_permission_vm_lxc_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." - }, "no_vmlxc_found": { "message": "No LXC or VM were found on the Proxmox VE server." }, diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index 1cf65ea78746..07c70348383c 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -1,5 +1,7 @@ """Tests for Proxmox VE integration.""" +from copy import deepcopy + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -53,6 +55,11 @@ MERGED_PERMISSIONS = { | set(SNAPSHOT_PERMISSIONS) } +PVEVMUSER_PERMISSIONS = deepcopy(MERGED_PERMISSIONS) +# Remove node-level and root-level scopes entirely +PVEVMUSER_PERMISSIONS.pop("/", None) +PVEVMUSER_PERMISSIONS.pop("/nodes", None) + async def setup_integration( hass: HomeAssistant, diff --git a/tests/components/proxmoxve/conftest.py b/tests/components/proxmoxve/conftest.py index 1decc74ac46c..8eab09af9095 100644 --- a/tests/components/proxmoxve/conftest.py +++ b/tests/components/proxmoxve/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.proxmoxve.const import ( CONF_TOKEN_SECRET, CONF_VMS, DOMAIN, + ProxmoxPermission, ) from homeassistant.const import ( CONF_HOST, @@ -124,8 +125,12 @@ def mock_proxmox_client(): node_mock.storage.get.return_value = load_json_array_fixture( "nodes/storage.json", DOMAIN ) - node_mock.tasks.get.return_value = load_json_array_fixture( - "nodes/tasks.json", DOMAIN + + node_mock.tasks.get.side_effect = lambda **kwargs: ( + [] + if ProxmoxPermission.SYSAUDIT + not in mock_instance.access.permissions.get.return_value.get("/nodes", []) + else load_json_array_fixture("nodes/tasks.json", DOMAIN) ) qemu_by_vmid = {vm["vmid"]: vm for vm in qemu_list} diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index ef752b8a613e..ff74d97cd4f7 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -652,407 +652,6 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_db-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'resume', - 'unique_id': '1234_101_resume', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db', - }), - 'context': , - 'entity_id': 'button.vm_db', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_create_snapshot', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Create snapshot', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Create snapshot', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'snapshot_create', - 'unique_id': '1234_101_snapshot_create', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Create snapshot', - }), - 'context': , - 'entity_id': 'button.vm_db_create_snapshot', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_hibernate', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Hibernate', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hibernate', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hibernate', - 'unique_id': '1234_101_hibernate', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Hibernate', - }), - 'context': , - 'entity_id': 'button.vm_db_hibernate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_reset', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Reset', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Reset', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'reset', - 'unique_id': '1234_101_reset', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Reset', - }), - 'context': , - 'entity_id': 'button.vm_db_reset', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Restart', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234_101_restart', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'restart', - : 'vm-db Restart', - }), - 'context': , - 'entity_id': 'button.vm_db_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_shut_down', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Shut down', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Shut down', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'shutdown', - 'unique_id': '1234_101_shutdown', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Shut down', - }), - 'context': , - 'entity_id': 'button.vm_db_shut_down', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_start-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_start', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Start', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Start', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'start', - 'unique_id': '1234_101_start', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Start', - }), - 'context': , - 'entity_id': 'button.vm_db_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.vm_db_stop', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Stop', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Stop', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'stop', - 'unique_id': '1234_101_stop', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Stop', - }), - 'context': , - 'entity_id': 'button.vm_db_stop', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_button_entities[button.vm_web-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/proxmoxve/test_binary_sensor.py b/tests/components/proxmoxve/test_binary_sensor.py index 4dd60e789f32..d1e2eb5c5983 100644 --- a/tests/components/proxmoxve/test_binary_sensor.py +++ b/tests/components/proxmoxve/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -80,3 +80,48 @@ async def test_refresh_exceptions( state = hass.states.get("binary_sensor.ct_nginx_status") assert state.state == STATE_UNAVAILABLE + + +async def test_binary_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are created when allowed.""" + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" in {e.entity_id for e in entries} + + +async def test_binary_sensors_absent_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" not in {e.entity_id for e in entries} diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index 2b8769949101..abf2bd171bdc 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -11,7 +11,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import AUDIT_PERMISSIONS, setup_integration @@ -362,61 +362,19 @@ async def test_container_buttons_exceptions( ) -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.pve1_shut_down", "no_permission_node_power"), - ("button.pve1_start_all", "no_permission_vm_lxc_power"), - ("button.ct_nginx_start", "no_permission_vm_lxc_power"), - ("button.vm_web_start", "no_permission_vm_lxc_power"), - ("button.vm_web_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_node_buttons_permission_denied_for_auditor_role( +async def test_buttons_only_allowed_buttons( hass: HomeAssistant, mock_proxmox_client: MagicMock, mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test that buttons are raising accordingly for Auditor permissions.""" + """Test that ProxmoxVE button is not generated when not allowed.""" mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS await setup_integration(hass, mock_config_entry) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) - -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.vm_db_start", "no_permission_vm_lxc_power"), - ("button.vm_db_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_vm_buttons_denied_for_specific_vm( - hass: HomeAssistant, - mock_proxmox_client: MagicMock, - mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, -) -> None: - """Test that button only works on actual permissions.""" - await setup_integration(hass, mock_config_entry) - mock_proxmox_client._node_mock.qemu(101) - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key + assert all(not entry.entity_id.startswith("button.") for entry in entries) diff --git a/tests/components/proxmoxve/test_sensor.py b/tests/components/proxmoxve/test_sensor.py index f4fc55cb97e5..a2109bbd0372 100644 --- a/tests/components/proxmoxve/test_sensor.py +++ b/tests/components/proxmoxve/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import ( MockConfigEntry, @@ -68,3 +68,26 @@ async def test_storage_missing_used_fraction( state = hass.states.get("sensor.storage_local_storage_usage_percentage") assert state.state == STATE_UNKNOWN + + +async def test_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "sensor.pve1_status" in {e.entity_id for e in entries} + assert "sensor.pve1_cpu" not in {e.entity_id for e in entries} From 57f10d2de3309d33f35ca3d9fd39c703bf745847 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 14 Jul 2026 21:03:09 +0200 Subject: [PATCH 606/707] Fix hash-seed dependent flakiness in test_setup_frontend_before_recorder (#176508) Co-authored-by: Claude Fable 5 --- tests/test_bootstrap.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index bc0ced1b5c3b..68c434592c32 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -619,9 +619,11 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert "recorder" in hass.config.components assert "http" in hass.config.components - assert order == [ - "http", - "an_after_dep", + # http (a dependency) and an_after_dep (an after_dependency) are both set + # up in the frontend substage of stage 0; their relative order depends on + # set iteration order and is not guaranteed. + assert set(order[:2]) == {"http", "an_after_dep"} + assert order[2:] == [ "frontend", "recorder", "normal_integration", From d61f3ec6804927894967aa289cb1d1947f400395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 21:25:19 +0200 Subject: [PATCH 607/707] Add diagnostics to nobo_hub (#176514) --- .../components/nobo_hub/diagnostics.py | 52 ++++++++++++++++++ .../components/nobo_hub/quality_scale.yaml | 2 +- tests/components/nobo_hub/conftest.py | 10 ++-- .../nobo_hub/snapshots/test_diagnostics.ambr | 54 +++++++++++++++++++ tests/components/nobo_hub/test_diagnostics.py | 21 ++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/nobo_hub/diagnostics.py create mode 100644 tests/components/nobo_hub/snapshots/test_diagnostics.ambr create mode 100644 tests/components/nobo_hub/test_diagnostics.py diff --git a/homeassistant/components/nobo_hub/diagnostics.py b/homeassistant/components/nobo_hub/diagnostics.py new file mode 100644 index 000000000000..62adeddc955c --- /dev/null +++ b/homeassistant/components/nobo_hub/diagnostics.py @@ -0,0 +1,52 @@ +"""Diagnostics support for Nobø Ecohub.""" + +from typing import Any + +from pynobo import ComponentInfo + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant + +from . import NoboHubConfigEntry +from .const import ATTR_SERIAL, CONF_SERIAL + +TO_REDACT_ENTRY = {CONF_IP_ADDRESS, CONF_MAC, CONF_SERIAL} +TO_REDACT_HUB = {ATTR_SERIAL} + +_MODEL_FIELDS = ( + "model_id", + "name", + "type", + "has_temp_sensor", + "requires_control_panel", + "supports_comfort", + "supports_eco", +) + + +def _component_to_dict(component: ComponentInfo) -> dict[str, Any]: + formatted = dict(component) + if (model := formatted.get("model")) is not None: + formatted["model"] = { + field: getattr(model, field, None) for field in _MODEL_FIELDS + } + return formatted + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NoboHubConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hub = entry.runtime_data + return { + "entry_data": async_redact_data(entry.data, TO_REDACT_ENTRY), + "hub_info": async_redact_data(hub.hub_info, TO_REDACT_HUB), + "zones": hub.zones, + "components": async_redact_data( + [_component_to_dict(c) for c in hub.components.values()], + TO_REDACT_HUB, + ), + "week_profiles": hub.week_profiles, + "overrides": hub.overrides, + } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index ce62526480a9..5c5dddba9d9b 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -48,7 +48,7 @@ rules: status: done comment: > Model name "Nobø Ecohub" under review for rename to "Nobø Hub". - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done docs-data-update: done diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 974e803740c3..0cab97fed99c 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -106,10 +106,12 @@ def mock_nobo_class( "temp_eco_c": "17", }, } - model = MagicMock() - # Direct assignment overrides MagicMock's auto-attr for `.name`. - model.name = "Panel heater" - model.has_temp_sensor = True + model = pynobo_nobo.Model( + model_id="183", + type="THERMOSTAT_FLOOR", + name="Panel heater", + has_temp_sensor=True, + ) hub.components = { "200000059091": { "serial": "200000059091", diff --git a/tests/components/nobo_hub/snapshots/test_diagnostics.ambr b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..72d02fa6421e --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'components': list([ + dict({ + 'model': dict({ + 'has_temp_sensor': True, + 'model_id': '183', + 'name': 'Panel heater', + 'requires_control_panel': False, + 'supports_comfort': False, + 'supports_eco': False, + 'type': 'THERMOSTAT_FLOOR', + }), + 'name': 'Floor sensor', + 'serial': '**REDACTED**', + 'zone_id': '1', + }), + ]), + 'entry_data': dict({ + 'ip_address': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'hub_info': dict({ + 'hardware_version': 'hw', + 'name': 'My Eco Hub', + 'serial': '**REDACTED**', + 'software_version': '115', + }), + 'overrides': dict({ + '988': dict({ + 'mode': '0', + 'target_id': '-1', + 'target_type': '0', + }), + }), + 'week_profiles': dict({ + '0': dict({ + 'name': 'Default', + 'profile': '00000', + 'week_profile_id': '0', + }), + }), + 'zones': dict({ + '1': dict({ + 'name': 'Living room', + 'temp_comfort_c': '21', + 'temp_eco_c': '17', + 'week_profile_id': '0', + 'zone_id': '1', + }), + }), + }) +# --- diff --git a/tests/components/nobo_hub/test_diagnostics.py b/tests/components/nobo_hub/test_diagnostics.py new file mode 100644 index 000000000000..e4cbebf2b716 --- /dev/null +++ b/tests/components/nobo_hub/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Tests for the Nobø Ecohub diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert result == snapshot From 886f76c9c41eb86cd640231b0801f7512fc5a533 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Jul 2026 21:38:44 +0200 Subject: [PATCH 608/707] Bump modbus-connection to 3.7.0 (#176521) Co-authored-by: Claude --- homeassistant/components/modbus_connection/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json index 7d78cd95d247..156d5f3e45a8 100644 --- a/homeassistant/components/modbus_connection/manifest.json +++ b/homeassistant/components/modbus_connection/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["modbus_connection", "tmodbus"], "quality_scale": "bronze", - "requirements": ["modbus-connection[tmodbus]==3.6.0"] + "requirements": ["modbus-connection[tmodbus]==3.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab54a315cbee..9422cf251412 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1601,7 +1601,7 @@ mitsubishi-comfort==0.3.2 moat-ble==0.1.1 # homeassistant.components.modbus_connection -modbus-connection[tmodbus]==3.6.0 +modbus-connection[tmodbus]==3.7.0 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 From f37df79107e57922cd320f00087ec75659d809b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 22:26:52 +0200 Subject: [PATCH 609/707] Redact serial from unknown component model name in diagnostics (#176524) --- .../components/nobo_hub/diagnostics.py | 15 +++++---- tests/components/nobo_hub/test_diagnostics.py | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nobo_hub/diagnostics.py b/homeassistant/components/nobo_hub/diagnostics.py index 62adeddc955c..7916848774cd 100644 --- a/homeassistant/components/nobo_hub/diagnostics.py +++ b/homeassistant/components/nobo_hub/diagnostics.py @@ -2,9 +2,9 @@ from typing import Any -from pynobo import ComponentInfo +from pynobo import ComponentInfo, nobo -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import HomeAssistant @@ -26,11 +26,12 @@ _MODEL_FIELDS = ( def _component_to_dict(component: ComponentInfo) -> dict[str, Any]: - formatted = dict(component) - if (model := formatted.get("model")) is not None: - formatted["model"] = { - field: getattr(model, field, None) for field in _MODEL_FIELDS - } + model = component["model"] + formatted: dict[str, Any] = dict(component) + formatted["model"] = {field: getattr(model, field, None) for field in _MODEL_FIELDS} + if model.type == nobo.Model.UNKNOWN: + # Unknown models carry the serial number in the name. + formatted["model"]["name"] = REDACTED return formatted diff --git a/tests/components/nobo_hub/test_diagnostics.py b/tests/components/nobo_hub/test_diagnostics.py index e4cbebf2b716..2fcdeb95328c 100644 --- a/tests/components/nobo_hub/test_diagnostics.py +++ b/tests/components/nobo_hub/test_diagnostics.py @@ -1,7 +1,11 @@ """Tests for the Nobø Ecohub diagnostics.""" +from unittest.mock import MagicMock + +from pynobo import nobo as pynobo_nobo from syrupy.assertion import SnapshotAssertion +from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -19,3 +23,31 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) assert result == snapshot + + +async def test_entry_diagnostics_redacts_unknown_model_name( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """An unknown model's name embeds the serial, so it is dropped; model_id is kept.""" + mock_nobo_hub.components = { + "999000012345": { + "serial": "999000012345", + "name": "Mystery device", + "zone_id": "1", + "model": pynobo_nobo.Model( + model_id="999", + type=pynobo_nobo.Model.UNKNOWN, + name="Unknown (serial number: 999 000 012 345)", + ), + }, + } + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + component = result["components"][0] + assert component["serial"] == REDACTED + assert component["model"]["model_id"] == "999" + assert component["model"]["name"] == REDACTED From aa15f864d21499a11cb599cada29f28bf86dfd33 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 15 Jul 2026 05:59:14 +0200 Subject: [PATCH 610/707] Add public API for discovered DHCP devices (#176443) --- homeassistant/components/dhcp/__init__.py | 5 +++ homeassistant/components/dhcp/helpers.py | 15 +++++++++ tests/components/dhcp/test_init.py | 40 +++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index f497b273bcc9..48f6077d5371 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -61,8 +61,13 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp from . import websocket_api from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from .helpers import async_discovered_service_info from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers +__all__ = [ + "async_discovered_service_info", +] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) REGISTERED_DEVICES: Final = "registered_devices" diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py index 7acf26f76fde..9c81fce1405c 100644 --- a/homeassistant/components/dhcp/helpers.py +++ b/homeassistant/components/dhcp/helpers.py @@ -4,7 +4,9 @@ from collections.abc import Callable from functools import partial from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .const import HOSTNAME, IP_ADDRESS from .models import DATA_DHCP, DHCPAddressData @@ -33,3 +35,16 @@ def async_get_address_data_internal( This is not intended for use by integrations. """ return hass.data[DATA_DHCP].address_data + + +@callback +def async_discovered_service_info(hass: HomeAssistant) -> list[DhcpServiceInfo]: + """Return the discovered DHCP devices.""" + return [ + DhcpServiceInfo( + ip=data[IP_ADDRESS], + hostname=data[HOSTNAME].lower(), + macaddress=mac_address, + ) + for mac_address, data in async_get_address_data_internal(hass).items() + ] diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 261372857ade..fe820e8b6d38 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -616,6 +616,46 @@ async def test_setup_and_stop(hass: HomeAssistant) -> None: resolve_iface_call.assert_called_once() +async def test_discovered_service_info(hass: HomeAssistant) -> None: + """Test getting the discovered DHCP devices from the cache.""" + saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None + + async def mock_start( + callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + if_indexes: list[int] | None = None, + ) -> None: + """Mock start.""" + nonlocal saved_callback + saved_callback = callback + + with ( + patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start), + patch("homeassistant.components.dhcp.DiscoverHosts"), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert dhcp.async_discovered_service_info(hass) == [] + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12")) + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "Sad", "44:44:33:11:23:13")) + + assert dhcp.async_discovered_service_info(hass) == [ + DhcpServiceInfo( + ip="4.3.2.2", + hostname="happy", + macaddress="444433112312", + ), + DhcpServiceInfo( + ip="4.3.2.1", + hostname="sad", + macaddress="444433112313", + ), + ] + + async def test_setup_fails_as_root( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From a72e1d6fb5ca92d74e91a258ecbfb062dddb4c8c Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Wed, 15 Jul 2026 00:23:56 -0400 Subject: [PATCH 611/707] Bump sense-energy to 0.14.3 (#176532) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index bc7ed9de5822..c551a3149eb8 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.14.1"] + "requirements": ["sense-energy==0.14.3"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 07187066dcde..dea32f63c792 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -21,5 +21,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.14.1"] + "requirements": ["sense-energy==0.14.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9422cf251412..48d64d4c3260 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2988,7 +2988,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.14.1 +sense-energy==0.14.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 2dc13a2b3b3fdc6cf2b2f860cc542408c4b2da11 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 15 Jul 2026 08:17:16 +0200 Subject: [PATCH 612/707] Bump aiomelcloudhome 0.2.1 (#176528) --- homeassistant/components/melcloud_home/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/melcloud_home/manifest.json b/homeassistant/components/melcloud_home/manifest.json index 0ba62597594d..63adffcef260 100644 --- a/homeassistant/components/melcloud_home/manifest.json +++ b/homeassistant/components/melcloud_home/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiomelcloudhome"], "quality_scale": "bronze", - "requirements": ["aiomelcloudhome==0.1.9"] + "requirements": ["aiomelcloudhome==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48d64d4c3260..1047a8af3b93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -342,7 +342,7 @@ aiolyric==2.1.1 aiomealie==1.2.4 # homeassistant.components.melcloud_home -aiomelcloudhome==0.1.9 +aiomelcloudhome==0.2.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 7420f3077f9917322df579fe166ad5404d146d04 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:19:30 +0300 Subject: [PATCH 613/707] Pump pyairobotrest to 0.4.0 (#176518) --- homeassistant/components/airobot/button.py | 16 +------------ .../components/airobot/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/airobot/test_button.py | 24 ------------------- 4 files changed, 3 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py index 46bb3847219c..1e35887be249 100644 --- a/homeassistant/components/airobot/button.py +++ b/homeassistant/components/airobot/button.py @@ -4,11 +4,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, override -from pyairobotrest.exceptions import ( - AirobotConnectionError, - AirobotError, - AirobotTimeoutError, -) +from pyairobotrest.exceptions import AirobotError from homeassistant.components.button import ( ButtonDeviceClass, @@ -32,7 +28,6 @@ class AirobotButtonEntityDescription(ButtonEntityDescription): """Describes Airobot button entity.""" press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]] - ignore_connection_errors: bool = False BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( @@ -41,7 +36,6 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda coordinator: coordinator.client.reboot_thermostat(), - ignore_connection_errors=True, ), AirobotButtonEntityDescription( key="recalibrate_co2", @@ -86,14 +80,6 @@ class AirobotButton(AirobotEntity, ButtonEntity): """Handle the button press.""" try: await self.entity_description.press_fn(self.coordinator) - except (AirobotConnectionError, AirobotTimeoutError) as err: - if not self.entity_description.ignore_connection_errors: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="button_press_failed", - translation_placeholders={"button": self.entity_description.key}, - ) from err - # Connection errors during reboot are expected as device restarts except AirobotError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/airobot/manifest.json b/homeassistant/components/airobot/manifest.json index 6a2e01f07325..76ef0a73e01e 100644 --- a/homeassistant/components/airobot/manifest.json +++ b/homeassistant/components/airobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "loggers": ["pyairobotrest"], "quality_scale": "platinum", - "requirements": ["pyairobotrest==0.3.0"] + "requirements": ["pyairobotrest==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1047a8af3b93..77a160a2642a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2027,7 +2027,7 @@ pyaftership==21.11.0 pyairnow==1.3.1 # homeassistant.components.airobot -pyairobotrest==0.3.0 +pyairobotrest==0.4.0 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py index 59b50b05d31e..4f638122926c 100644 --- a/tests/components/airobot/test_button.py +++ b/tests/components/airobot/test_button.py @@ -71,30 +71,6 @@ async def test_restart_button_error( mock_airobot_client.reboot_thermostat.assert_called_once() -@pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize( - "exception", - [AirobotConnectionError("Connection lost"), AirobotTimeoutError("Timeout")], -) -async def test_restart_button_connection_errors( - hass: HomeAssistant, - mock_airobot_client: AsyncMock, - exception: Exception, -) -> None: - """Test restart button handles connection/timeout errors gracefully.""" - mock_airobot_client.reboot_thermostat.side_effect = exception - - # Should not raise an error - connection errors during reboot are expected - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_thermostat_restart"}, - blocking=True, - ) - - mock_airobot_client.reboot_thermostat.assert_called_once() - - @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_recalibrate_co2_button( hass: HomeAssistant, From e53c88ccd9eab485b4dc00d9ab5000ab93814dc0 Mon Sep 17 00:00:00 2001 From: Martin Hoefling Date: Wed, 15 Jul 2026 08:21:45 +0200 Subject: [PATCH 614/707] Bump knx-telegram-store to 0.10.1 (#176529) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index e0f5ba00e766..4c372bb876e7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.16.0", "xknxproject==3.9.0", "knx-frontend==2026.6.23.203726", - "knx-telegram-store[sqlite,postgres]==0.9.1" + "knx-telegram-store[sqlite,postgres]==0.10.1" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 77a160a2642a..c3401e2c9bd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1438,7 +1438,7 @@ knocki==0.4.2 knx-frontend==2026.6.23.203726 # homeassistant.components.knx -knx-telegram-store[sqlite,postgres]==0.9.1 +knx-telegram-store[sqlite,postgres]==0.10.1 # homeassistant.components.kraken krakenex==2.2.2 From 40d78665bc699c576d0725e8cab04a53c1d82caf Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 15 Jul 2026 09:40:56 +0200 Subject: [PATCH 615/707] Bump oralb-ble to 1.1.1 (#176515) --- homeassistant/components/oralb/icons.json | 3 +- homeassistant/components/oralb/manifest.json | 2 +- homeassistant/components/oralb/sensor.py | 10 +---- homeassistant/components/oralb/strings.json | 4 +- requirements_all.txt | 2 +- tests/components/oralb/__init__.py | 22 +++++++++ tests/components/oralb/test_sensor.py | 47 ++++++++++++++++++++ 7 files changed, 77 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/oralb/icons.json b/homeassistant/components/oralb/icons.json index 7f28dede4ae1..a3b464edc1d2 100644 --- a/homeassistant/components/oralb/icons.json +++ b/homeassistant/components/oralb/icons.json @@ -37,8 +37,7 @@ "sector_1": "mdi:circle-slice-2", "sector_2": "mdi:circle-slice-4", "sector_3": "mdi:circle-slice-6", - "sector_4": "mdi:circle-slice-8", - "success": "mdi:check-circle-outline" + "sector_4": "mdi:circle-slice-8" } }, "toothbrush_state": { diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index a15ea81e9067..7bc928e46e5b 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["oralb_ble"], - "requirements": ["oralb-ble==1.1.0"] + "requirements": ["oralb-ble==1.1.1"] } diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 286defcf35f8..28fb2be64c74 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -3,13 +3,7 @@ from typing import override from oralb_ble import OralBSensor, SensorUpdate -from oralb_ble.parser import ( - IO_SERIES_MODES, - PRESSURE, - SECTOR_MAP, - SMART_SERIES_MODES, - STATES, -) +from oralb_ble.parser import IO_SERIES_MODES, PRESSURE, SMART_SERIES_MODES, STATES from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, @@ -46,7 +40,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { key=OralBSensor.SECTOR, translation_key="sector", entity_category=EntityCategory.DIAGNOSTIC, - options=[v.replace(" ", "_") for v in set(SECTOR_MAP.values()) | {"no_sector"}], + options=["no_sector", *(f"sector_{sector}" for sector in range(1, 8))], device_class=SensorDeviceClass.ENUM, ), OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index 2aa29d12f13e..de7e62978a48 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -60,7 +60,9 @@ "sector_2": "Sector 2", "sector_3": "Sector 3", "sector_4": "Sector 4", - "success": "Success" + "sector_5": "Sector 5", + "sector_6": "Sector 6", + "sector_7": "Sector 7" } }, "sector_timer": { diff --git a/requirements_all.txt b/requirements_all.txt index c3401e2c9bd4..99dd167bf899 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1804,7 +1804,7 @@ openwrt-ubus-rpc==0.0.3 opower==0.18.6 # homeassistant.components.oralb -oralb-ble==1.1.0 +oralb-ble==1.1.1 # homeassistant.components.oru oru==0.1.11 diff --git a/tests/components/oralb/__init__.py b/tests/components/oralb/__init__.py index 757a10d22a1c..593d9101e1fc 100644 --- a/tests/components/oralb/__init__.py +++ b/tests/components/oralb/__init__.py @@ -37,6 +37,28 @@ ORALB_IO_SERIES_4_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO = BluetoothServiceInfo( + name="Oral-B Toothbrush", + address="78:DB:2F:C2:48:BE", + rssi=-63, + # running, 6 sectors, currently in sector 5 + manufacturer_data={220: b"\x062\x0c\x03\x00\x00\x1e\x00\x05\x0a\x06"}, + service_uuids=[], + service_data={}, + source="local", +) + +ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO = BluetoothServiceInfo( + name="Oral-B Toothbrush", + address="78:DB:2F:C2:48:BE", + rssi=-63, + # running, 6 sectors, "last sector" sentinel (7) resolves to sector 6 + manufacturer_data={220: b"\x062\x0c\x03\x00\x00\x28\x00\x07\x0a\x06"}, + service_uuids=[], + service_data={}, + source="local", +) + ORALB_IO_SERIES_6_SERVICE_INFO = BluetoothServiceInfoBleak( name="Oral-B Toothbrush", address="B0:D2:78:20:1D:CF", diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index a6b51694a188..c3a40a3bd8e7 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.bluetooth import ( async_address_present, ) from homeassistant.components.oralb.const import DOMAIN +from homeassistant.components.sensor import ATTR_OPTIONS from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -17,6 +18,8 @@ from homeassistant.util import dt as dt_util from . import ( ORALB_IO_SERIES_4_SERVICE_INFO, ORALB_IO_SERIES_6_SERVICE_INFO, + ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO, + ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO, ORALB_SERVICE_INFO, ) @@ -141,6 +144,50 @@ async def test_sensors_io_series_4(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sector_sensor_six_sectors(hass: HomeAssistant) -> None: + """Test the sector sensor while brushing with a six sector routine.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + inject_bluetooth_service_info(hass, ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO) + await hass.async_block_till_done() + + sector_sensor = hass.states.get("sensor.io_series_48be_sector") + assert sector_sensor.state == "sector_5" + assert sector_sensor.attributes[ATTR_OPTIONS] == [ + "no_sector", + "sector_1", + "sector_2", + "sector_3", + "sector_4", + "sector_5", + "sector_6", + "sector_7", + ] + + number_of_sectors_sensor = hass.states.get( + "sensor.io_series_48be_number_of_sectors" + ) + assert number_of_sectors_sensor.state == "6" + + # The "last sector" sentinel resolves to the sector count (sector 6) + inject_bluetooth_service_info(hass, ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO) + await hass.async_block_till_done() + + sector_sensor = hass.states.get("sensor.io_series_48be_sector") + assert sector_sensor.state == "sector_6" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_sensors_battery(hass: HomeAssistant) -> None: """Test receiving battery percentage.""" entry = MockConfigEntry( From 58531b589bfeff6d55e66f34a9bf278968e5d78c Mon Sep 17 00:00:00 2001 From: Thomas Gosteli Date: Wed, 15 Jul 2026 10:16:15 +0200 Subject: [PATCH 616/707] Bump python-swisscom-internet-box to 0.2.0 (#176519) Signed-off-by: Thomas Gosteli --- homeassistant/components/swisscom/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json index 8b259e82d90d..6beb51f2b7fe 100644 --- a/homeassistant/components/swisscom/manifest.json +++ b/homeassistant/components/swisscom/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/swisscom", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["python-swisscom-internet-box==0.1.1"] + "requirements": ["python-swisscom-internet-box==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 99dd167bf899..bc3b99511ff9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2764,7 +2764,7 @@ python-snoo==0.8.3 python-songpal==0.16.2 # homeassistant.components.swisscom -python-swisscom-internet-box==0.1.1 +python-swisscom-internet-box==0.2.0 # homeassistant.components.tado python-tado==0.18.16 From fc255b9cc33bbc41a81d21ebd8901acf3775bc69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:22:07 +0200 Subject: [PATCH 617/707] Bump github/codeql-action/init from 4.36.3 to 4.37.0 (#176539) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 080b4e0a1d2f..4acc6bf7c89f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: python From 77ee9e2ca53d653bd581b11396f0a6a4d7d0cd63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:23:08 +0200 Subject: [PATCH 618/707] Bump github/codeql-action/analyze from 4.36.3 to 4.37.0 (#176540) Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4acc6bf7c89f..4e1287cc395d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,6 +33,6 @@ jobs: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:python" From 730735a81b40aee95d07a5ee2fd87249f0185727 Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Wed, 15 Jul 2026 05:02:16 -0700 Subject: [PATCH 619/707] Extract shared base entity for Subaru (#176246) --- homeassistant/components/subaru/button.py | 11 ++--- .../components/subaru/device_tracker.py | 25 ++++------- homeassistant/components/subaru/entity.py | 45 +++++++++++++++++++ homeassistant/components/subaru/lock.py | 11 ++--- homeassistant/components/subaru/sensor.py | 21 ++------- 5 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/subaru/entity.py diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py index 24ea65eb465d..2e77211698ce 100644 --- a/homeassistant/components/subaru/button.py +++ b/homeassistant/components/subaru/button.py @@ -10,15 +10,14 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( SERVICE_REMOTE_START, SERVICE_REMOTE_STOP, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_START, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruEntity from .remote_service import async_call_remote_service @@ -59,10 +58,9 @@ async def async_setup_entry( ) -class SubaruButton(ButtonEntity): +class SubaruButton(SubaruEntity, ButtonEntity): """Class for a Subaru button.""" - _attr_has_entity_name = True entity_description: SubaruButtonEntityDescription def __init__( @@ -73,13 +71,10 @@ class SubaruButton(ButtonEntity): description: SubaruButtonEntityDescription, ) -> None: """Initialize the button for the vehicle.""" + super().__init__(vehicle_info, description.key) self.controller = controller self.coordinator = coordinator - self.vehicle_info = vehicle_info self.entity_description = description - vin = vehicle_info[VEHICLE_VIN] - self._attr_unique_id = f"{vin}_{description.key}" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_press(self) -> None: diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index 9ea7929b5dcf..f31ac633893b 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -7,11 +7,10 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import get_device_info -from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS, VEHICLE_VIN +from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity async def async_setup_entry( @@ -29,23 +28,17 @@ async def async_setup_entry( ) -class SubaruDeviceTracker( - CoordinatorEntity[SubaruDataUpdateCoordinator], TrackerEntity -): +class SubaruDeviceTracker(SubaruCoordinatorEntity, TrackerEntity): """Class for Subaru device tracker.""" _attr_translation_key = "location" - _attr_has_entity_name = True _attr_name = None def __init__( self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator ) -> None: """Initialize the device tracker.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_location" + super().__init__(vehicle_info, coordinator, "location") @property @override @@ -72,8 +65,8 @@ class SubaruDeviceTracker( @property @override def available(self) -> bool: - """Return if entity is available.""" - if vehicle_data := self.coordinator.data.get(self.vin): - if status := vehicle_data.get(VEHICLE_STATUS): - return status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP} - return False + """Return if available; not gated on last_update_success, only on the relevant status keys being present.""" + if not (vehicle_data := (self.coordinator.data or {}).get(self.vin)): + return False + status = vehicle_data.get(VEHICLE_STATUS) or {} + return bool(status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP}) diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py new file mode 100644 index 000000000000..a9e4ff154615 --- /dev/null +++ b/homeassistant/components/subaru/entity.py @@ -0,0 +1,45 @@ +"""Base entities for the Subaru integration.""" + +from typing import Any, override + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import get_device_info +from .const import VEHICLE_VIN +from .coordinator import SubaruDataUpdateCoordinator + + +class SubaruEntity(Entity): + """Base class for Subaru entities: device_info, unique_id, has_entity_name.""" + + _attr_has_entity_name = True + + def __init__(self, vehicle_info: dict[str, Any], unique_id_suffix: str) -> None: + """Initialize the entity from the vehicle_info dict.""" + self.vehicle_info = vehicle_info + self.vin: str = vehicle_info[VEHICLE_VIN] + self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_{unique_id_suffix}" + + +class SubaruCoordinatorEntity( + CoordinatorEntity[SubaruDataUpdateCoordinator], SubaruEntity +): + """Base class for coordinator-backed Subaru entities.""" + + def __init__( + self, + vehicle_info: dict[str, Any], + coordinator: SubaruDataUpdateCoordinator, + unique_id_suffix: str, + ) -> None: + """Initialize the coordinator-backed entity.""" + super().__init__(coordinator) + SubaruEntity.__init__(self, vehicle_info, unique_id_suffix) + + @property + @override + def available(self) -> bool: + """Return if available; also gates on data for this vehicle being present.""" + return super().available and self.vin in self.coordinator.data diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 62547ee51e5b..362e3ebe4c3d 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( ATTR_DOOR, SERVICE_UNLOCK_SPECIFIC_DOOR, @@ -19,9 +18,9 @@ from .const import ( UNLOCK_VALID_DOORS, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_NAME, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry +from .entity import SubaruEntity from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,7 @@ async def async_setup_entry( ) -class SubaruLock(LockEntity): +class SubaruLock(SubaruEntity, LockEntity): """Representation of a Subaru door lock. Note that the Subaru API currently does not support @@ -58,17 +57,13 @@ class SubaruLock(LockEntity): always unknown. """ - _attr_has_entity_name = True _attr_translation_key = "door_locks" def __init__(self, vehicle_info, controller): """Initialize the locks for the vehicle.""" + super().__init__(vehicle_info, "door_locks") self.controller = controller - self.vehicle_info = vehicle_info - vin = vehicle_info[VEHICLE_VIN] self.car_name = vehicle_info[VEHICLE_NAME] - self._attr_unique_id = f"{vin}_door_locks" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 1a49fbba510d..4fff8efb10ba 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -27,11 +27,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import METRIC_SYSTEM -from . import get_device_info from .const import ( API_GEN_2, API_GEN_3, @@ -42,9 +40,9 @@ from .const import ( VEHICLE_HAS_EV, VEHICLE_HEALTH, VEHICLE_STATUS, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -260,10 +258,9 @@ def create_vehicle_sensors( ] -class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity): +class SubaruSensor(SubaruCoordinatorEntity, SensorEntity): """Class for Subaru sensors.""" - _attr_has_entity_name = True entity_description: SubaruSensorEntityDescription def __init__( @@ -273,11 +270,8 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity) description: SubaruSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] + super().__init__(vehicle_info, coordinator, description.key) self.entity_description = description - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_{description.key}" @property @override @@ -312,15 +306,6 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity) return FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS return self.entity_description.native_unit_of_measurement - @property - @override - def available(self) -> bool: - """Return if entity is available.""" - last_update_success = super().available - if last_update_success and self.vin not in self.coordinator.data: - return False - return last_update_success - async def _async_migrate_entries( hass: HomeAssistant, config_entry: ConfigEntry From cb75cee32ea84abca5270b53e9a2dad35d62a6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Wed, 15 Jul 2026 14:20:07 +0200 Subject: [PATCH 620/707] Add stale device removal to nobo_hub (#176511) --- homeassistant/components/nobo_hub/__init__.py | 31 +++- homeassistant/components/nobo_hub/climate.py | 6 + .../components/nobo_hub/quality_scale.yaml | 2 +- homeassistant/components/nobo_hub/select.py | 6 + homeassistant/components/nobo_hub/sensor.py | 6 + tests/components/nobo_hub/__init__.py | 25 ++- tests/components/nobo_hub/test_climate.py | 31 +++- tests/components/nobo_hub/test_init.py | 159 +++++++++++++++++- tests/components/nobo_hub/test_select.py | 38 ++++- tests/components/nobo_hub/test_sensor.py | 41 ++++- 10 files changed, 326 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index daf5611f0424..faed74a2a16f 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -116,6 +116,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + @callback + def _cleanup_devices(_hub: nobo) -> None: + """Remove devices for zones and components no longer on the hub.""" + if not hub.connected: + # While disconnected pynobo may hold stale topology; only reconcile + # against a live, fully-synced hub. + return + expected_identifiers = {(DOMAIN, hub.hub_serial)} + expected_identifiers.update( + (DOMAIN, f"{hub.hub_serial}:{zone_id}") for zone_id in hub.zones + ) + expected_identifiers.update((DOMAIN, serial) for serial in hub.components) + # Runs inside pynobo's update-callback dispatch: removing a device + # deregisters its entities' callbacks mid-iteration, which can skip a + # following callback. Safe because a pynobo message carries a single + # topology change, so a removal never coincides with a surviving + # entity's update in the same dispatch. + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if device.identifiers.isdisjoint(expected_identifiers): + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + _cleanup_devices(hub) + hub.register_callback(_cleanup_devices) + entry.async_on_unload(lambda: hub.deregister_callback(_cleanup_devices)) + await hub.start() return True diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 06552658b0ec..aa09b8fba97f 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -69,6 +69,12 @@ async def async_setup_entry( @callback def _add_zones(_hub: nobo) -> None: """Add climate entities for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] known_zones.update(new_zones) async_add_entities( diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 5c5dddba9d9b..6ad1081c7d7b 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -69,7 +69,7 @@ rules: repair-issues: status: exempt comment: Integration has no repair scenarios. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 40b85798d42f..85ad51e78e04 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -47,6 +47,12 @@ async def async_setup_entry( @callback def _add_profiles(_hub: nobo) -> None: """Add week-profile selectors for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] known_zones.update(new_zones) async_add_entities( diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 88bc76bf1506..371fa96e6823 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -35,6 +35,12 @@ async def async_setup_entry( @callback def _add_sensors(_hub: nobo) -> None: """Add temperature sensors for components added to the hub.""" + if hub.connected: + # Forget components no longer on the hub so a removed-then-re-added + # component is detected as new again. Skip while disconnected: a + # stale/empty snapshot would drop live components and cause + # duplicate re-adds on reconnect. + known_components.intersection_update(hub.components) new_components = [ serial for serial, component in hub.components.items() diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index 48e57be118be..4f3be1f6f48b 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -3,7 +3,17 @@ from unittest.mock import MagicMock from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er + + +def device_identifiers( + device_registry: dr.DeviceRegistry, entry_id: str +) -> set[tuple[str, str]]: + """Return the identifiers of all devices for the config entry.""" + identifiers: set[tuple[str, str]] = set() + for device in dr.async_entries_for_config_entry(device_registry, entry_id): + identifiers |= device.identifiers + return identifiers def entity_unique_ids(entity_registry: er.EntityRegistry, entry_id: str) -> set[str]: @@ -14,10 +24,19 @@ def entity_unique_ids(entity_registry: er.EntityRegistry, entry_id: str) -> set[ } -async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: - """Fire the hub's registered push-update callbacks and wait for state to settle.""" +def dispatch_hub_update(hub: MagicMock) -> None: + """Fire the hub's registered push-update callbacks without awaiting. + + Mirrors pynobo dispatching a single message: call this twice in a row to + reproduce buffered messages processed with no event-loop yield between them. + """ for call in hub.register_callback.call_args_list: call.args[0](hub) + + +async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: + """Fire the hub's registered push-update callbacks and wait for state to settle.""" + dispatch_hub_update(hub) await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py index 2ea1baf77b3f..a2ec77f978ea 100644 --- a/tests/components/nobo_hub/test_climate.py +++ b/tests/components/nobo_hub/test_climate.py @@ -27,7 +27,7 @@ from homeassistant.components.nobo_hub.const import ( DOMAIN, OVERRIDE_TYPE_NOW, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -197,14 +197,14 @@ async def test_set_preset_with_override_type_now( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_unavailable( +async def test_zone_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(CLIMATE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(CLIMATE_ENTITY) is None @pytest.mark.usefixtures("init_integration") @@ -289,3 +289,26 @@ async def test_new_zone_adds_entity( await fire_hub_update(hass, mock_nobo_hub) assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) reappears.""" + entry_id = mock_config_entry.entry_id + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 880aa49dd74f..9a8d8c005b87 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -14,9 +14,15 @@ from homeassistant.components.nobo_hub.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import fire_hub_connection +from . import ( + device_identifiers, + dispatch_hub_update, + entity_unique_ids, + fire_hub_connection, + fire_hub_update, +) from .conftest import SERIAL, STORED_IP from tests.common import MockConfigEntry @@ -324,3 +330,152 @@ async def test_zone_removed_during_disconnect_stays_unavailable_on_reconnect( await fire_hub_connection(hass, mock_nobo_hub, True) assert hass.states.get(entity).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_removed_zone_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a zone on the hub removes its device but keeps the hub device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, f"{SERIAL}:1") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.zones["1"] + await fire_hub_update(hass, mock_nobo_hub) + + identifiers = device_identifiers(device_registry, entry_id) + assert (DOMAIN, f"{SERIAL}:1") not in identifiers + assert (DOMAIN, SERIAL) in identifiers + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_removed_component_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a temperature-sensor component on the hub removes its device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, "200000059091") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.components["200000059091"] + await fire_hub_update(hass, mock_nobo_hub) + + assert (DOMAIN, "200000059091") not in device_identifiers(device_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_disconnected_hub_does_not_remove_devices( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices are retained when topology looks empty because the hub is disconnected.""" + entry_id = mock_config_entry.entry_id + before = device_identifiers(device_registry, entry_id) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + assert device_identifiers(device_registry, entry_id) == before + + +@pytest.mark.parametrize( + "platforms", + [[Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_disconnect_does_not_readd_entities_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale empty topology while disconnected must not forget known ids. + + Otherwise the reconcile would clear the known-id sets and re-add every + entity on reconnect, colliding with the still-registered unique ids. + """ + saved_zones = dict(mock_nobo_hub.zones) + saved_components = dict(mock_nobo_hub.components) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + mock_nobo_hub.connected = True + mock_nobo_hub.zones.update(saved_zones) + mock_nobo_hub.components.update(saved_components) + await fire_hub_update(hass, mock_nobo_hub) + + assert "already exists" not in caplog.text + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_buffered_remove_then_readd_same_id( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A buffered remove + same-id re-add (no event-loop yield between) re-registers cleanly. + + pynobo can process a delete and an id-reusing add back-to-back before the + loop yields (buffered messages), so synchronous device removal must fully + deregister the old entity before the re-add, or the add collides with the + still-registered unique id. + """ + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + # Remove then re-add the same id with no await (no event-loop yield) between. + del mock_nobo_hub.zones["2"] + dispatch_hub_update(mock_nobo_hub) + mock_nobo_hub.zones["2"] = zone + dispatch_hub_update(mock_nobo_hub) + await hass.async_block_till_done() + + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + assert "already exists" not in caplog.text + + +@pytest.mark.usefixtures("mock_nobo_class") +async def test_stale_device_pruned_at_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """A device for a zone removed while Home Assistant was down is pruned at setup.""" + mock_config_entry.add_to_hass(hass) + stale_device = (DOMAIN, f"{SERIAL}:99") + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={stale_device}, + ) + entry_id = mock_config_entry.entry_id + assert stale_device in device_identifiers(device_registry, entry_id) + + assert await hass.config_entries.async_setup(entry_id) + await hass.async_block_till_done() + + assert stale_device not in device_identifiers(device_registry, entry_id) diff --git a/tests/components/nobo_hub/test_select.py b/tests/components/nobo_hub/test_select.py index e9a6eaa7c58f..31c401543cd9 100644 --- a/tests/components/nobo_hub/test_select.py +++ b/tests/components/nobo_hub/test_select.py @@ -12,7 +12,7 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -154,14 +154,44 @@ async def test_week_profile_push_update( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_week_profile_unavailable( +async def test_zone_removed_removes_week_profile_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(PROFILE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(PROFILE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears_profile_selector( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) restores its selector.""" + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/nobo_hub/test_sensor.py b/tests/components/nobo_hub/test_sensor.py index 8a8c3e3404ba..12fad4c74a7b 100644 --- a/tests/components/nobo_hub/test_sensor.py +++ b/tests/components/nobo_hub/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -58,14 +58,47 @@ async def test_temperature_push_update( @pytest.mark.usefixtures("init_integration") -async def test_component_removed_marks_unavailable( +async def test_component_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A component removed via the Nobø app must not crash and goes unavailable.""" + """Removing a component via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.components.pop("200000059091") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(TEMPERATURE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_component_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A component removed and re-added under the same serial (the hub reuses serials) reappears.""" + entry_id = mock_config_entry.entry_id + serial = "200000059092" + model = MagicMock() + model.name = "Panel heater" + model.has_temp_sensor = True + component = { + "serial": serial, + "name": "Bedroom sensor", + "zone_id": "1", + "model": model, + } + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.components[serial] + await fire_hub_update(hass, mock_nobo_hub) + assert serial not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) @pytest.mark.parametrize( From 2ddccf84a6becefe9171530ccaaeb6d227a95794 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 15 Jul 2026 14:46:59 +0200 Subject: [PATCH 621/707] Add timeout to user config flow in SMTP integration (#176538) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/smtp/config_flow.py | 76 +++++++++++++------- homeassistant/components/smtp/const.py | 1 + homeassistant/components/smtp/strings.json | 11 +++ tests/components/smtp/conftest.py | 17 ++--- tests/components/smtp/test_config_flow.py | 34 ++++++--- 5 files changed, 90 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index 52a7641aa5f2..28e43b3c9388 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -10,6 +10,7 @@ from typing import Any, override import voluptuous as vol +from homeassistant import data_entry_flow from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ( SOURCE_USER, @@ -59,11 +60,28 @@ from .const import ( DEFAULT_TIMEOUT, DOMAIN, ENCRYPTION_OPTIONS, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) _LOGGER = logging.getLogger(__name__) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, + max=1800, + step=1, + unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ) + } +) STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -115,23 +133,6 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( } ) -OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( - NumberSelector( - NumberSelectorConfig( - min=1, - max=1800, - step=1, - unit_of_measurement=UnitOfTime.SECONDS, - mode=NumberSelectorMode.BOX, - ) - ), - vol.Coerce(int), - ) - } -) - class MailConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMTP.""" @@ -166,16 +167,29 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + entry_data = user_input.copy() + options = entry_data.pop(SECTION_OPTIONS) + errors = await self.hass.async_add_executor_job( + validate_input, entry_data, options + ) if not errors: return self.async_create_entry( - title=user_input.get(CONF_SENDER_NAME, user_input[CONF_SENDER]), - data=user_input, + title=entry_data.get(CONF_SENDER_NAME, entry_data[CONF_SENDER]), + data=entry_data, + options=options, ) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + data_schema=STEP_USER_DATA_SCHEMA.extend( + { + vol.Required(SECTION_OPTIONS): data_entry_flow.section( + OPTIONS_SCHEMA, + {"collapsed": True}, + ), + } + ), + suggested_values=user_input, ), errors=errors, ) @@ -209,7 +223,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + errors = await self.hass.async_add_executor_job( + validate_input, user_input, dict(entry.options) + ) if not errors: return self.async_update_and_abort( entry, @@ -240,7 +256,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: errors = await self.hass.async_add_executor_job( - validate_input, {**entry.data, **user_input} + validate_input, {**entry.data, **user_input}, dict(entry.options) ) if not errors: return self.async_update_and_abort( @@ -263,7 +279,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)} self._async_abort_entries_match(import_info) - errors = await self.hass.async_add_executor_job(validate_input, import_info) + errors = await self.hass.async_add_executor_job( + validate_input, import_info, options + ) if not errors: title = ( import_info.get(CONF_NAME) @@ -288,7 +306,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=errors["base"]) -def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +def validate_input( + user_input: dict[str, Any], options: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" errors: dict[str, str] = {} ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None @@ -298,12 +318,14 @@ def validate_input(user_input: dict[str, Any]) -> dict[str, str]: mail = SMTP_SSL( user_input[CONF_SERVER], user_input[CONF_PORT], - timeout=DEFAULT_TIMEOUT, + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), context=ssl_context, ) else: mail = SMTP( - user_input[CONF_SERVER], user_input[CONF_PORT], timeout=DEFAULT_TIMEOUT + user_input[CONF_SERVER], + user_input[CONF_PORT], + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) mail.ehlo_or_helo_if_needed() if user_input[CONF_ENCRYPTION] == "starttls": diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index dc9fccd3d5ab..78fb8d99cf27 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -11,6 +11,7 @@ ATTR_SENDER_NAME: Final = "sender_name" CONF_ENCRYPTION: Final = "encryption" CONF_SERVER: Final = "server" CONF_SENDER_NAME: Final = "sender_name" +SECTION_OPTIONS: Final = "options" DEFAULT_HOST: Final = "localhost" DEFAULT_PORT: Final = 587 diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 9908b7c9f521..c48c4798e429 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -67,6 +67,17 @@ "server": "Hostname or IP address of the SMTP server. For example, `smtp.example.com`.", "username": "Username used to authenticate with the SMTP server.", "verify_ssl": "Enable certificate verification for secure SSL/TLS connections." + }, + "sections": { + "options": { + "data": { + "timeout": "[%key:component::smtp::options::step::init::data::timeout%]" + }, + "data_description": { + "timeout": "[%key:component::smtp::options::step::init::data_description::timeout%]" + }, + "name": "Additional options" + } } } } diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index 4af1b2e703e5..27336b6a7cb6 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -51,11 +51,13 @@ def mock_smtp() -> Generator[MagicMock]: with ( patch( - "homeassistant.components.smtp.helpers.smtplib.SMTP", autospec=True + "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True ) as mock_client, + patch("homeassistant.components.smtp.helpers.smtplib.SMTP", new=mock_client), patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client), ): client = mock_client.return_value + client.cls = mock_client yield client @@ -70,17 +72,6 @@ def mock_make_msgid() -> Generator[None]: yield -@pytest.fixture(name="smtp_ssl") -def mock_smtp_ssl() -> Generator[MagicMock]: - """Mock SMTP.""" - - with patch( - "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True - ) as mock_client: - client = mock_client.return_value - yield client - - @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Mock smtp configuration entry.""" @@ -89,7 +80,7 @@ def mock_config_entry() -> MockConfigEntry: title="Home Assistant", data=USER_INPUT, options={ - CONF_TIMEOUT: 5, + CONF_TIMEOUT: 1312, }, entry_id="123456789", subentries_data=[ diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index fd8f82d13e21..2708daee3e90 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.smtp.const import ( CONF_ENCRYPTION, CONF_SENDER_NAME, DOMAIN, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) from homeassistant.config_entries import ( @@ -37,10 +38,9 @@ from .conftest import USER_INPUT from tests.common import MockConfigEntry -@pytest.mark.usefixtures("smtp", "smtp_ssl") @pytest.mark.parametrize("encryption", ["tls", "starttls"]) async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str, smtp: MagicMock ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -54,6 +54,7 @@ async def test_form( { **USER_INPUT, CONF_ENCRYPTION: encryption, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, }, ) await hass.async_block_till_done() @@ -64,6 +65,7 @@ async def test_form( **USER_INPUT, CONF_ENCRYPTION: encryption, } + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 await hass.async_block_till_done(wait_background_tasks=True) @@ -79,6 +81,8 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Recipient" assert result["unique_id"] == "recipient@example.com" + assert smtp.cls.call_args[0] == ("mail.example.com", 587) + assert smtp.cls.call_args[1]["timeout"] == 60 @pytest.mark.usefixtures("smtp") @@ -98,7 +102,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() @@ -134,7 +141,10 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) assert result["type"] is FlowResultType.FORM @@ -144,13 +154,17 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" assert result["data"] == USER_INPUT + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 @@ -215,9 +229,8 @@ async def test_options_flow( } -@pytest.mark.usefixtures("smtp") async def test_form_reconfigure( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock ) -> None: """Test reconfigure flow.""" @@ -250,6 +263,7 @@ async def test_form_reconfigure( } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.usefixtures("smtp") @@ -358,8 +372,9 @@ async def test_form_reconfigure_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("smtp") -async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_form_reauth( + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock +) -> None: """Test reauth flow.""" config_entry.add_to_hass(hass) @@ -388,6 +403,7 @@ async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) - } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.parametrize( From f8c2e1a801bf069822c7a490f7f575a078682c6e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 15 Jul 2026 15:01:29 +0200 Subject: [PATCH 622/707] Add support for Somfy Thermostat PRO climate entity in Overkiz (#176283) --- .../components/overkiz/climate/__init__.py | 3 + homeassistant/components/overkiz/const.py | 1 + .../cloud_somfy_tahoma_switch_sc_europe.json | 1110 +++++++++++++++++ .../overkiz/snapshots/test_climate.ambr | 82 ++ tests/components/overkiz/test_climate.py | 67 + 5 files changed, 1263 insertions(+) diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 4f56034d03c0..e68c9d95b68a 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -57,6 +57,9 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: ( + ValveHeatingTemperatureInterface + ), UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: AtlanticPassAPCHeatPumpMainComponent, } diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index b0cbe6f9c8a8..6748b59fa545 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -119,6 +119,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, } diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json index 4c127ecb54d5..5582c6aff293 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json @@ -3483,6 +3483,1116 @@ "widget": "ZigbeeStack", "oid": "08fa5c0f-95ba-410a-8a66-4cb55c0d508c", "uiClass": "ProtocolGateway" + }, + { + "label": "Thermostat", + "uiClass": "HeatingSystem", + "deviceURL": "io://1234-5678-5010/386310#1", + "shortcut": false, + "controllableName": "io:HeatingThermostatIOComponent", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "addLockLevel", + "nparams": 2 + }, + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1 + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "setTimeProgramById", + "nparams": 2 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "exitDerogation", + "nparams": 0 + }, + { + "commandName": "setAllModeTemperatures", + "nparams": 4 + }, + { + "commandName": "setDerogation", + "nparams": 2 + }, + { + "commandName": "setThermostatSettings", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:ActiveTimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:BatteryLevelState" + }, + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ComfortRoomTemperatureState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:CommandLockLevelsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:DerogatedTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationEndDateTimeState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationStartDateTimeState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:EcoTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:FrostProtectionRoomTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:HeatingAnticipationState" + }, + { + "type": "DataState", + "qualifiedName": "core:MaxSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:MinSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open"], + "qualifiedName": "core:OpenClosedValveState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:OpenWindowDetectionActivationState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:PermanentDisplayState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetRoomTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureHysteresisState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureOffsetConfigurationState" + }, + { + "type": "DiscreteState", + "values": ["cooling", "heating", "heatingAndCooling"], + "qualifiedName": "core:ThermalConfigurationState" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram1State" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram2State" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:AwayModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:CurrentHeatingModeState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:DerogationHeatingModeState" + }, + { + "type": "DiscreteState", + "values": ["date", "furtherNotice", "nextMode"], + "qualifiedName": "io:DerogationTypeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:GeofencingModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "io:LockKeyActivationState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:ManualModeTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:OpenWindowDetectedTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "adjustment", + "finished", + "full_closed", + "full_open", + "pairing", + "reset" + ], + "qualifiedName": "io:ValveInstallationModeState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "ThermostatHeatingTemperatureInterface", + "uiProfiles": ["ThermostatTargetReader"], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "io:HeatingThermostatIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "io:DerogationTypeState", + "type": 3, + "value": "further_notice" + }, + { + "name": "io:DerogationHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:DerogatedTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "io:ManualModeTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:DerogationStartDateTimeState", + "type": 5, + "value": 1779390409000 + }, + { + "name": "core:DerogationEndDateTimeState", + "type": 5, + "value": 4294967295000 + }, + { + "name": "core:ComfortRoomTemperatureState", + "type": 2, + "value": 21.0 + }, + { + "name": "io:AwayModeTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "core:EcoTargetTemperatureState", + "type": 2, + "value": 19.0 + }, + { + "name": "io:GeofencingModeTargetTemperatureState", + "type": 2, + "value": 20.0 + }, + { + "name": "core:FrostProtectionRoomTemperatureState", + "type": 2, + "value": 8.0 + }, + { + "name": "io:OpenWindowDetectedTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "io:ValveInstallationModeState", + "type": 3, + "value": "finished" + }, + { + "name": "core:BatteryLevelState", + "type": 2, + "value": 100.0 + }, + { + "name": "core:TimeProgram1State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:TimeProgram2State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:OpenClosedValveState", + "type": 3, + "value": "open" + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "io:CurrentHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:TargetRoomTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:OpenWindowDetectionActivationState", + "type": 3, + "value": "active" + }, + { + "name": "io:LockKeyActivationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:PermanentDisplayState", + "type": 3, + "value": "enable" + }, + { + "name": "core:ThermalConfigurationState", + "type": 3, + "value": "heating" + }, + { + "name": "core:HeatingAnticipationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:ActiveTimeProgramState", + "type": 3, + "value": "none" + }, + { + "name": "core:MaxSetpointState", + "type": 2, + "value": 26.0 + }, + { + "name": "core:MinSetpointState", + "type": 2, + "value": 5.0 + }, + { + "name": "core:TemperatureOffsetConfigurationState", + "type": 2, + "value": 0.0 + }, + { + "name": "core:TargetTemperatureHysteresisState", + "type": 2, + "value": 0.3 + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "d241a2c8-713a-428a-9911-0f8226af676e", + "widget": "ThermostatHeatingTemperatureInterface", + "type": 1 + }, + { + "label": "Thermostat Temperature", + "uiClass": "TemperatureSensor", + "deviceURL": "io://1234-5678-5010/386310#2", + "shortcut": false, + "controllableName": "io:TemperatureIOSystemSensor", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureState" + } + ], + "dataProperties": [], + "widgetName": "TemperatureSensor", + "uiProfiles": ["Temperature"], + "uiClass": "TemperatureSensor", + "qualifiedName": "io:TemperatureIOSystemSensor", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "core:TemperatureState", + "type": 2, + "value": 26.6 + } + ], + "attributes": [ + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155003A14" + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:MaxSensedValue", + "type": 2, + "value": 655.35 + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "battery" + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "c32eb2cd-06de-4827-95fb-51ae49acf467", + "widget": "TemperatureSensor", + "type": 2 } ], "zones": [], diff --git a/tests/components/overkiz/snapshots/test_climate.ambr b/tests/components/overkiz/snapshots/test_climate.ambr index cef1c9b44662..91e4b14aa5d9 100644 --- a/tests/components/overkiz/snapshots/test_climate.ambr +++ b/tests/components/overkiz/snapshots/test_climate.ambr @@ -656,3 +656,85 @@ 'state': 'heat_cool', }) # --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.study_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-5010/386310#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 26.6, + : 'Thermostat', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'manual', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 16.5, + }), + 'context': , + 'entity_id': 'climate.study_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py index 74a47ca41ed0..8d11067804c8 100644 --- a/tests/components/overkiz/test_climate.py +++ b/tests/components/overkiz/test_climate.py @@ -13,6 +13,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, HVACAction, HVACMode, ) @@ -22,6 +23,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration from .helpers import ( + assert_command_call, async_deliver_events, device_available_event, device_removed_event, @@ -55,11 +57,18 @@ YUTAKI_ZONE_2 = FixtureDevice( "modbus://1234-5678-2284/5416194/1#3", "climate.somfy_tahoma_switch_yutaki_zone_2", ) +# io:HeatingThermostatIOComponent +THERMOSTAT_HEATING = FixtureDevice( + "setup/cloud_somfy_tahoma_switch_sc_europe.json", + "io://1234-5678-5010/386310#1", + "climate.study_thermostat", +) SNAPSHOT_FIXTURES = [ VALVE, COZYTOUCH, YUTAKI_ZONE_1, + THERMOSTAT_HEATING, ] @@ -178,3 +187,61 @@ async def test_hitachi_air_to_water_heating_zone_2( assert zone_2.state == HVACMode.AUTO assert zone_2.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5 assert zone_2.attributes[ATTR_TEMPERATURE] == 21.0 + + +async def test_thermostat_heating_set_temperature( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """Test setting a temperature issues setDerogation, not setComfortTemperature.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_TEMPERATURE: 20.0}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=[20.0, "further_notice"], + ) + + +@pytest.mark.parametrize( + ("preset_mode", "parameters"), + [ + pytest.param("away", ["away", "further_notice"], id="away"), + pytest.param("comfort", ["comfort", "further_notice"], id="comfort"), + pytest.param("eco", ["eco", "further_notice"], id="eco"), + # Manual re-sends the current temperature to enter the derogation + pytest.param("manual", [26.6, "further_notice"], id="manual"), + ], +) +async def test_thermostat_heating_set_preset_mode( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, + preset_mode: str, + parameters: list[str | float], +) -> None: + """Test selecting a preset issues setDerogation with the mapped parameter.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=parameters, + ) From 20a338ad2feac3eeb72d217d193b02bc33bac5b0 Mon Sep 17 00:00:00 2001 From: Felix Schneider Date: Wed, 15 Jul 2026 15:10:51 +0200 Subject: [PATCH 623/707] [Overseerr] feat: implement searching and request media (#176483) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/overseerr/const.py | 7 +- homeassistant/components/overseerr/icons.json | 6 + .../components/overseerr/services.py | 131 +++++++++++++++++- .../components/overseerr/services.yaml | 37 +++++ .../components/overseerr/strings.json | 42 ++++++ tests/components/overseerr/conftest.py | 1 + tests/components/overseerr/test_services.py | 121 +++++++++++++++- 7 files changed, 338 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index b955d2a50a40..a48ac7669b41 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,9 +9,14 @@ LOGGER = logging.getLogger(__package__) REQUESTS = "requests" +ATTR_MEDIA_TYPE = "media_type" +ATTR_QUERY = "query" +ATTR_REQUESTED_BY = "requested_by" +ATTR_SEASONS = "seasons" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" -ATTR_REQUESTED_BY = "requested_by" +ATTR_MEDIA_ID = "media_id" + EVENT_KEY = f"{DOMAIN}_event" diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json index 9b63943f8989..290aa0a976dc 100644 --- a/homeassistant/components/overseerr/icons.json +++ b/homeassistant/components/overseerr/icons.json @@ -32,6 +32,12 @@ "services": { "get_requests": { "service": "mdi:multimedia" + }, + "request_media": { + "service": "mdi:download" + }, + "search_media": { + "service": "mdi:magnify" } } } diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 5354102472ca..9405b21ea6dd 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -1,7 +1,8 @@ """Define services for the Overseerr integration.""" +import ast from dataclasses import asdict -from typing import Any, cast +from typing import Any, Literal, cast from python_overseerr import OverseerrClient, OverseerrConnectionError import voluptuous as vol @@ -18,10 +19,23 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import service from homeassistant.util.json import JsonValueType -from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER +from .const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, + ATTR_REQUESTED_BY, + ATTR_SEASONS, + ATTR_SORT_ORDER, + ATTR_STATUS, + DOMAIN, + LOGGER, +) from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" +SERVICE_SEARCH_MEDIA = "search_media" +SERVICE_REQUEST_MEDIA = "request_media" + SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): str, @@ -33,6 +47,29 @@ SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( } ) +SERVICE_SEARCH_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_QUERY): str, + } +) + +SERVICE_REQUEST_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEDIA_TYPE): vol.In(["movie", "tv"]), + vol.Required(ATTR_MEDIA_ID): vol.All( + vol.Coerce(int), + vol.Range(min=1), + ), + vol.Optional(ATTR_SEASONS): vol.Any( + vol.Coerce(int), + [vol.Coerce(int)], + str, + ), + } +) + async def _get_media( client: OverseerrClient, media_type: str, identifier: int @@ -52,7 +89,7 @@ async def _get_media( async def _async_get_requests(call: ServiceCall) -> ServiceResponse: - """Get requests made to Overseerr.""" + """Get requests made to Seerr.""" entry: OverseerrConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) @@ -92,9 +129,79 @@ async def _async_get_requests(call: ServiceCall) -> ServiceResponse: return {"requests": cast(list[JsonValueType], result)} +async def _async_search_media(call: ServiceCall) -> ServiceResponse: + """Search for media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + query = call.data[ATTR_QUERY] + + LOGGER.debug("Searching for '%s'", query) + try: + search_results = await client.search(query) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return { + "results": cast( + list[JsonValueType], [asdict(result) for result in search_results] + ) + } + + +async def _async_request_media(call: ServiceCall) -> ServiceResponse: + """Request media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + media_type = call.data[ATTR_MEDIA_TYPE] + media_id = call.data[ATTR_MEDIA_ID] + seasons = parse_seasons_input(call.data.get(ATTR_SEASONS)) + + LOGGER.debug( + "Requesting %s with media ID %s (seasons: %s)", + media_type, + media_id, + seasons or "none", + ) + try: + # We can always pass in the seasons, they will be ignored if the media type isn't TV + request = await client.create_request(media_type, media_id, seasons) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return {"request": cast(JsonValueType, asdict(request))} + + +def parse_seasons_input(seasons_input: Any | None) -> Literal["all"] | list[int]: + """Parse all possible inputs to "all" or a list of integers.""" + seasons_str = str(seasons_input).strip() + if seasons_input is None or seasons_str in ("", "all"): + return "all" + + try: + parsed = ast.literal_eval(seasons_str) + if isinstance(parsed, int): + return [parsed] + return [int(season) for season in parsed] + except ValueError, SyntaxError, TypeError: + LOGGER.error("Unable to cast input to a list '%s'", seasons_input) + return "all" + + @callback def async_setup_services(hass: HomeAssistant) -> None: - """Set up the services for the Overseerr integration.""" + """Set up the services for the Seerr integration.""" hass.services.async_register( DOMAIN, @@ -103,3 +210,19 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_REQUESTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SEARCH_MEDIA, + _async_search_media, + schema=SERVICE_SEARCH_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_MEDIA, + _async_request_media, + schema=SERVICE_REQUEST_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/overseerr/services.yaml b/homeassistant/components/overseerr/services.yaml index c7593fc5aee1..3fcf49ba8f5d 100644 --- a/homeassistant/components/overseerr/services.yaml +++ b/homeassistant/components/overseerr/services.yaml @@ -28,3 +28,40 @@ get_requests: number: min: 0 mode: box + +search_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + query: + required: true + selector: + text: + +request_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + media_type: + required: true + selector: + select: + options: + - movie + - tv + translation_key: request_media_type + media_id: + required: true + selector: + number: + min: 1 + mode: box + seasons: + selector: + text: diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 9ddfc6929f6d..aa139f6cf919 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -118,6 +118,12 @@ } }, "selector": { + "request_media_type": { + "options": { + "movie": "Movie", + "tv": "TV" + } + }, "request_sort_order": { "options": { "added": "Added", @@ -157,6 +163,42 @@ } }, "name": "Get requests" + }, + "request_media": { + "description": "Creates a media request in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to create the request on.", + "name": "Seerr instance" + }, + "media_id": { + "description": "The TMDB ID or TVDB ID of the media to request.", + "name": "Media ID" + }, + "media_type": { + "description": "Type of media to request.", + "name": "Media type" + }, + "seasons": { + "description": "For TV requests: seasons to request. Optional list of integers (e.g., [1, 2, 4]). If omitted, all seasons will be requested.", + "name": "Seasons" + } + }, + "name": "Request media" + }, + "search_media": { + "description": "Searches for media in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to search.", + "name": "Seerr instance" + }, + "query": { + "description": "The search query.", + "name": "Query" + } + }, + "name": "Search media" } } } diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index 5435aff659c5..8c9d45c99f58 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -67,6 +67,7 @@ def mock_overseerr_client() -> Generator[AsyncMock]: client.get_tv_details.return_value = TVDetails.from_json( load_fixture("tv.json", DOMAIN) ) + client.search.return_value = [] yield client diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 39df5760693d..07279dff3efd 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -1,18 +1,29 @@ """Tests for the Overseerr services.""" +import dataclasses from unittest.mock import AsyncMock import pytest from python_overseerr import OverseerrConnectionError +from python_overseerr.models import MediaType from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, ATTR_REQUESTED_BY, + ATTR_SEASONS, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) -from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.components.overseerr.services import ( + SERVICE_GET_REQUESTS, + SERVICE_REQUEST_MEDIA, + SERVICE_SEARCH_MEDIA, + parse_seasons_input, +) from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -75,6 +86,65 @@ async def test_service_get_requests_no_meta( assert request["media"] == {} +async def test_service_search_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the search_media service.""" + # Mock the search method + mock_overseerr_client.search.return_value = [] + + await setup_integration(hass, mock_config_entry) + + # Test with a query containing spaces + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_QUERY: "test query with spaces", + }, + blocking=True, + return_response=True, + ) + assert response == {"results": []} + mock_overseerr_client.search.assert_called_once_with("test query with spaces") + + +async def test_service_request_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the request_media service.""" + + # Mock the create request method + @dataclasses.dataclass + class RequestWithMediaMock: + tmdb_id: str = "123456789" + media_type: MediaType = MediaType.TV + + mock_overseerr_client.create_request.return_value = RequestWithMediaMock() + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_REQUEST_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEDIA_TYPE: "tv", + ATTR_MEDIA_ID: "123456789", + ATTR_SEASONS: "1", + }, + blocking=True, + return_response=True, + ) + + assert response == {"request": {"media_type": MediaType.TV, "tmdb_id": "123456789"}} + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [ @@ -85,7 +155,23 @@ async def test_service_get_requests_no_meta( OverseerrConnectionError("Timeout"), HomeAssistantError, "Error connecting to the Seerr instance: Timeout", - ) + ), + ( + SERVICE_SEARCH_MEDIA, + {ATTR_QUERY: "test"}, + "search", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + "create_request", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), ], ) async def test_services_connection_error( @@ -119,6 +205,11 @@ async def test_services_connection_error( ("service", "payload"), [ (SERVICE_GET_REQUESTS, {}), + (SERVICE_SEARCH_MEDIA, {ATTR_QUERY: "test"}), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + ), ], ) async def test_service_entry_availability( @@ -154,3 +245,29 @@ async def test_service_entry_availability( return_response=True, ) assert err.value.translation_key == "service_config_entry_not_found" + + +@pytest.mark.parametrize( + ("seasons_input", "expected_seasons"), + [ + ("1", [1]), + ("1,", [1]), + ("1,2,3", [1, 2, 3]), + ("1, 2, 3", [1, 2, 3]), + (" 1 , 2, 3 ", [1, 2, 3]), + ("[1]", [1]), + ("[1,2,3]", [1, 2, 3]), + ("[ 1 , 2 , 3]", [1, 2, 3]), + ("", "all"), + (" ", "all"), + (None, "all"), + ("all", "all"), + ("Not a valid input", "all"), + ("-", "all"), + ], +) +def test_parse_seasons_input( + seasons_input: str | None, expected_seasons: list[int] | str +) -> None: + """Test that all inputs are parsed correctly.""" + assert expected_seasons == parse_seasons_input(seasons_input) From 0f63e05d5a0b47d12a10103dd7b3e291cbc9aea0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:14:25 +0200 Subject: [PATCH 624/707] Update syrupy to 5.5.2 (#176257) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d6cabd86c687..797ef3a7fa85 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.5.1 +syrupy==5.5.2 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 From d554d4ce7aa896dd59cc0fa6992bbba4b2b599f8 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:32:30 -0400 Subject: [PATCH 625/707] Improve Sonos error message on UPNP 800 - music service unavailable (#176167) --- homeassistant/components/sonos/helpers.py | 24 +++++++- homeassistant/components/sonos/strings.json | 9 +++ tests/components/sonos/test_media_player.py | 64 +++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 2b4df23b4ccc..db7229c2e15c 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import dispatcher_send -from .const import SONOS_SPEAKER_ACTIVITY +from .const import DOMAIN, SONOS_SPEAKER_ACTIVITY from .exception import SonosUpdateError if TYPE_CHECKING: @@ -30,6 +30,8 @@ if TYPE_CHECKING: UID_PREFIX = "RINCON_" UID_POSTFIX = "01400" +UPNP_ERROR_COMMAND_FAILED = "800" + _LOGGER = logging.getLogger(__name__) type _SonosEntitiesType = ( @@ -76,8 +78,24 @@ def soco_error[_T: _SonosEntitiesType, **_P, _R]( if (target := _find_target_identifier(self, args_soco)) is None: raise RuntimeError("Unexpected use of soco_error") from err - message = f"Error calling {function} on {target}: {err}" - raise SonosUpdateError(message) from err + translation_key = "call_failed" + placeholders = { + "target": target, + "error": str(err), + } + + if error_code is not None: + translation_key = "upnp_call_failed" + placeholders["error_code"] = str(error_code) + + if str(error_code) == UPNP_ERROR_COMMAND_FAILED: + translation_key = "upnp_call_failed_music_service_unavailable" + + raise SonosUpdateError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) from err dispatch_soco = args_soco or self.soco # type: ignore[union-attr] dispatcher_send( diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index f2e01da70fa3..44276051d9da 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -109,6 +109,9 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "call_failed": { + "message": "Error on {target}: {error}" + }, "entity_not_found": { "message": "Entity {entity_id} not found." }, @@ -141,6 +144,12 @@ }, "toggle_failed": { "message": "Could not toggle {entity_id}." + }, + "upnp_call_failed": { + "message": "Error on {target} (UPnP error code {error_code}): {error}" + }, + "upnp_call_failed_music_service_unavailable": { + "message": "Error on {target} (UPnP error code {error_code}): {error}. This may indicate the selected music service is not available on the speaker." } }, "issues": { diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 4dac45065347..18db04c55ca3 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -13,6 +13,7 @@ from soco.data_structures import ( DidlPlaylistContainer, SearchResult, ) +from soco.exceptions import SoCoUPnPException from sonos_websocket.exception import SonosWebsocketError from syrupy.assertion import SnapshotAssertion @@ -326,6 +327,69 @@ async def test_play_media_library_content_error( ) +@pytest.mark.parametrize( + ("error", "translation_key", "translation_placeholders"), + [ + pytest.param( + OSError("Network down"), + "call_failed", + { + "target": "media_player.zone_a", + "error": "Network down", + }, + id="generic-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 701 received", "701", ""), + "upnp_call_failed", + { + "target": "media_player.zone_a", + "error": "UPnP Error 701 received", + "error_code": "701", + }, + id="upnp-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 800 received", "800", ""), + "upnp_call_failed_music_service_unavailable", + { + "target": "media_player.zone_a", + "error": "UPnP Error 800 received", + "error_code": "800", + }, + id="upnp-error-800-music-service-unavailable", + ), + ], +) +async def test_play_media_error_translation( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + error: Exception, + translation_key: str, + translation_placeholders: dict[str, str], +) -> None: + """Test play_media surfaces translated error details for failures.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + soco_mock.play_uri.side_effect = error + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "track", + ATTR_MEDIA_CONTENT_ID: _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.REPLACE, + }, + blocking=True, + ) + + assert err.value.translation_key == translation_key + assert err.value.translation_placeholders == translation_placeholders + + _track_url = "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3" From 32c06395a21206d7fc33a0e68ce57f41a89f38ae Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 15 Jul 2026 15:39:42 +0200 Subject: [PATCH 626/707] Starline group executor job (#174667) --- .../components/starline/config_flow.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index 80fa4d9f8af2..f7cda744db7f 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -1,6 +1,6 @@ """Config flow to configure StarLine component.""" -from typing import override +from typing import TYPE_CHECKING, override from starline import StarlineAuth import voluptuous as vol @@ -192,13 +192,18 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Authenticate application.""" try: - self._app_code = await self.hass.async_add_executor_job( - self._auth.get_app_code, self._app_id, self._app_secret - ) - # pylint: disable-next=home-assistant-sequential-executor-jobs - self._app_token = await self.hass.async_add_executor_job( - self._auth.get_app_token, self._app_id, self._app_secret, self._app_code - ) + + def _get_app_token() -> str: + if TYPE_CHECKING: + assert self._app_id is not None + assert self._app_secret is not None + + app_code = self._auth.get_app_code(self._app_id, self._app_secret) + return self._auth.get_app_token( + self._app_id, self._app_secret, app_code + ) + + self._app_token = await self.hass.async_add_executor_job(_get_app_token) return self._async_form_auth_user(error) except Exception as err: # noqa: BLE001 _LOGGER.error("Error auth StarLine: %s", err) From 577d344b56998fe67d7c563023f2fd2c00a36ff1 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:44:12 -0400 Subject: [PATCH 627/707] Add Sonos button entity to cancel active announcements (#176337) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonos/button.py | 50 ++++++ homeassistant/components/sonos/const.py | 2 + homeassistant/components/sonos/icons.json | 5 + .../components/sonos/media_player.py | 6 +- homeassistant/components/sonos/speaker.py | 37 ++++- homeassistant/components/sonos/strings.json | 14 ++ .../sonos/snapshots/test_diagnostics.ambr | 2 + tests/components/sonos/test_button.py | 143 ++++++++++++++++++ 8 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/sonos/button.py create mode 100644 tests/components/sonos/test_button.py diff --git a/homeassistant/components/sonos/button.py b/homeassistant/components/sonos/button.py new file mode 100644 index 000000000000..c286a9363c9f --- /dev/null +++ b/homeassistant/components/sonos/button.py @@ -0,0 +1,50 @@ +"""Button entities for Sonos.""" + +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SONOS_CREATE_BUTTON +from .entity import SonosEntity +from .helpers import SonosConfigEntry +from .speaker import SonosSpeaker + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sonos button entities from a config entry.""" + + @callback + def async_create_entities(speaker: SonosSpeaker) -> None: + """Handle device discovery and create button entities.""" + async_add_entities([SonosCancelAnnouncementButton(speaker, config_entry)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_BUTTON, async_create_entities) + ) + + +class SonosCancelAnnouncementButton(SonosEntity, ButtonEntity): + """Button to cancel the current Sonos announcement.""" + + _attr_translation_key = "cancel_announcement" + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the cancel announcement button.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-cancel_announcement" + + @override + async def _async_fallback_poll(self) -> None: + """No-op: button state does not need polling.""" + + @override + async def async_press(self) -> None: + """Cancel the current announcement audio clip.""" + await self.speaker.async_cancel_announcement() diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 3142e72e6854..07d7d11ea461 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -11,6 +11,7 @@ DOMAIN = "sonos" DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, @@ -159,6 +160,7 @@ PLAYABLE_MEDIA_TYPES = [ SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" +SONOS_CREATE_BUTTON = "sonos_create_button" SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" diff --git a/homeassistant/components/sonos/icons.json b/homeassistant/components/sonos/icons.json index e28e4c305a99..2c16c854be9a 100644 --- a/homeassistant/components/sonos/icons.json +++ b/homeassistant/components/sonos/icons.json @@ -5,6 +5,11 @@ "default": "mdi:microphone" } }, + "button": { + "cancel_announcement": { + "default": "mdi:cancel" + } + }, "sensor": { "audio_input_format": { "default": "mdi:import" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index d1f5fb1b2cc4..94de448dc6ae 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -17,6 +17,7 @@ from soco.core import ( from soco.data_structures import DidlFavorite, DidlMusicTrack from soco.exceptions import SoCoException from soco.ms_data_structures import MusicServiceItem +from sonos_websocket import CLIP_ID_KEY from sonos_websocket.exception import SonosWebsocketError from homeassistant.components import media_source, spotify @@ -528,8 +529,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: + self.speaker.last_announce_id = None assert self.speaker.websocket - response, _ = await self.speaker.websocket.play_clip( + response, data = await self.speaker.websocket.play_clip( async_process_play_media_url(self.hass, media_id), volume=volume, ) @@ -538,6 +540,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): f"Error when calling Sonos websocket: {exc}" ) from exc if response.get("success"): + if data: + self.speaker.last_announce_id = data.get(CLIP_ID_KEY) return if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS: # If the speaker does not support announce do not raise and diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f55204491156..24cd43bbcb3e 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -17,10 +17,11 @@ from soco.plugins.plex import PlexPlugin from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot from sonos_websocket import SonosWebsocket +from sonos_websocket.exception import SonosWebsocketError from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -44,6 +45,7 @@ from .const import ( SONOS_CREATE_ALARM, SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY, + SONOS_CREATE_BUTTON, SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, @@ -186,6 +188,9 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] = [] self._group_members_missing: set[str] = set() + # Announcement tracking + self.last_announce_id: str | None = None + async def async_setup( self, entry: SonosConfigEntry, @@ -261,6 +266,7 @@ class SonosSpeaker: dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) + dispatches.append((SONOS_CREATE_BUTTON, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -1294,6 +1300,35 @@ class SonosSpeaker: any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() + async def async_cancel_announcement(self) -> None: + """Cancel the current announcement audio clip.""" + if self.last_announce_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_no_id", + ) + if not self.websocket: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": "websocket not available"}, + ) + try: + response, _ = await self.websocket.cancel_clip(self.last_announce_id) + except SonosWebsocketError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": str(exc)}, + ) from exc + if not response.get("success"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_error", + translation_placeholders={"response": str(response)}, + ) + self.last_announce_id = None + # # Media and playback state handlers # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 44276051d9da..82c99a7e1d23 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -18,6 +18,11 @@ "name": "Microphone" } }, + "button": { + "cancel_announcement": { + "name": "Cancel announcement" + } + }, "number": { "audio_delay": { "name": "Audio delay" @@ -109,9 +114,18 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "announcement_connection_error": { + "message": "Failed to reach Sonos speaker for announcement: {error}" + }, "call_failed": { "message": "Error on {target}: {error}" }, + "cancel_announcement_error": { + "message": "Cancelling announcement failed: {response}" + }, + "cancel_announcement_no_id": { + "message": "No active announcement to cancel" + }, "entity_not_found": { "message": "Entity {entity_id} not found." }, diff --git a/tests/components/sonos/snapshots/test_diagnostics.ambr b/tests/components/sonos/snapshots/test_diagnostics.ambr index 9e3dfcb47e79..a4b1500de962 100644 --- a/tests/components/sonos/snapshots/test_diagnostics.ambr +++ b/tests/components/sonos/snapshots/test_diagnostics.ambr @@ -20,6 +20,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', @@ -112,6 +113,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', diff --git a/tests/components/sonos/test_button.py b/tests/components/sonos/test_button.py new file mode 100644 index 000000000000..60a2617bcd8c --- /dev/null +++ b/tests/components/sonos/test_button.py @@ -0,0 +1,143 @@ +"""Tests for the Sonos button platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from sonos_websocket import CLIP_ID_KEY +from sonos_websocket.exception import SonosWebsocketError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +CANCEL_ANNOUNCEMENT_BUTTON = "button.zone_a_cancel_announcement" + + +async def _announce_clip(hass: HomeAssistant, content_id: str) -> None: + """Play an announcement clip to set the active clip id.""" + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + +async def test_cancel_announcement_no_prior( + hass: HomeAssistant, + async_autosetup_sonos, +) -> None: + """Test cancelling when no announcement has been played.""" + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +async def test_cancel_announcement( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling a currently playing announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock(return_value=[{"success": 1}, {}]) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + sonos_websocket.cancel_clip.assert_called_once_with("clip-123") + + +async def test_cancel_announcement_no_clip_id_from_announce_response( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling fails when the announce response has no clip ID.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [{"success": 1}, None] + await _announce_clip(hass, content_id) + + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("cancel_clip_side_effect", "cancel_clip_return", "error_match"), + [ + pytest.param( + SonosWebsocketError("Connection lost"), + None, + "Failed to reach Sonos speaker for announcement: Connection lost", + id="websocket_error", + ), + pytest.param( + None, + [{"success": 0}, {}], + "Cancelling announcement failed", + id="non_success_response", + ), + ], +) +async def test_cancel_announcement_errors( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, + cancel_clip_side_effect: SonosWebsocketError | None, + cancel_clip_return: list[dict[str, Any]] | None, + error_match: str, +) -> None: + """Test error handling when cancelling an announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock( + side_effect=cancel_clip_side_effect, + return_value=cancel_clip_return, + ) + with pytest.raises(HomeAssistantError, match=error_match): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) From 4b24077c8c078d2cf403a54079a70c7dac049af7 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:49:45 +0200 Subject: [PATCH 628/707] Migrate UniFi Protect FloodLight to the public API (#174650) Co-authored-by: Joost Lekkerkerker --- .../components/unifiprotect/binary_sensor.py | 8 +- .../components/unifiprotect/light.py | 26 ++++-- .../components/unifiprotect/number.py | 4 +- .../components/unifiprotect/select.py | 6 +- .../components/unifiprotect/sensor.py | 22 ++++-- .../components/unifiprotect/switch.py | 4 +- .../components/unifiprotect/utils.py | 19 +++-- .../unifiprotect/test_binary_sensor.py | 55 ++++++------- tests/components/unifiprotect/test_light.py | 79 +++++++++++++++---- tests/components/unifiprotect/test_number.py | 38 ++++++++- tests/components/unifiprotect/test_select.py | 67 +++++++++++++++- tests/components/unifiprotect/test_sensor.py | 52 ++++++++++++ tests/components/unifiprotect/test_switch.py | 56 ++++++++++++- tests/components/unifiprotect/utils.py | 53 +++++++++++-- 14 files changed, 397 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index b4bacac0192d..9b66204c64ea 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -302,18 +302,18 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", translation_key="is_dark", - ufp_value="is_dark", + ufp_public_value="is_dark", ), ProtectBinaryEntityDescription( key="motion", device_class=BinarySensorDeviceClass.MOTION, - ufp_value="is_pir_motion_detected", + ufp_public_value="is_pir_motion_detected", ), ProtectBinaryEntityDescription( key="light", translation_key="flood_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="is_light_on", + ufp_public_value="is_light_on", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( @@ -328,7 +328,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="status_light", translation_key="status_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.is_indicator_enabled", + ufp_public_value="light_device_settings.is_indicator_enabled", ufp_perm=PermRequired.NO_WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 1dd90b65079d..e5e42b5bb65e 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,10 +1,11 @@ """Component providing Lights for UniFi Protect.""" import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel from uiprotect.data.devices import LightDeviceSettings +from uiprotect.data.public_devices import PublicLight from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback @@ -61,14 +62,29 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _state_attrs = ("_attr_available", "_attr_is_on", "_attr_brightness") + @override + async def async_added_to_hass(self) -> None: + """Read state from the public API (primed before the first update).""" + self._ufp_uses_public = True + self._ufp_public_obj = self.data.async_get_public_device(self.device) + self.async_on_remove( + self.data.async_subscribe_public( + self.device.mac, self._async_public_updated + ) + ) + await super().async_added_to_hass() + @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) - updated_device = self.device - self._attr_is_on = updated_device.is_light_on - self._attr_brightness = unifi_brightness_to_hass( - updated_device.light_device_settings.led_level + if (public := self._ufp_public_obj) is None: + return + light = cast(PublicLight, public) + self._attr_is_on = light.is_light_on + led_level = light.light_device_settings.led_level + self._attr_brightness = ( + None if led_level is None else unifi_brightness_to_hass(led_level) ) @async_ufp_instance_command diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index fd95c888b16e..2f7cf224e209 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -173,8 +173,8 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=100, ufp_step=1, - ufp_value="light_device_settings.pir_sensitivity", - ufp_set_method="set_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", + ufp_set_method="set_sensitivity_public", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7789a3face2f..888267d346a5 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -51,7 +51,7 @@ from .entity import ( async_all_device_entities, async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current, async_ufp_instance_command +from .utils import async_get_light_motion_current_public, async_ufp_instance_command _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -173,7 +173,7 @@ def _get_doorbell_current(obj: Camera) -> str | None: async def _set_light_mode(obj: Light, mode: str) -> None: lightmode, timing = LIGHT_MODE_TO_SETTINGS[mode] - await obj.set_light_settings( + await obj.set_light_mode_public( LightModeType(lightmode), enable_at=None if timing is None else LightModeEnableType(timing), ) @@ -308,7 +308,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( translation_key="light_mode", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_set_method_fn=_set_light_mode, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 49d1e8a3fd32..cb3896cbd2c4 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from functools import partial import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import ( NVR, @@ -16,7 +16,12 @@ from uiprotect.data import ( ProtectDeviceModel, Sensor, ) -from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicLight, + SensorFeatureCapability, +) +from uiprotect.utils import convert_to_datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -52,7 +57,7 @@ from .entity import ( async_all_device_entities, async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current +from .utils import async_get_light_motion_current_public _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -90,6 +95,11 @@ class ProtectSensorEventEntityDescription( """Describes UniFi Protect Sensor entity.""" +def _get_last_motion_public(obj: PublicDeviceModel) -> datetime | None: + # Public API reports last motion as a JS epoch (ms); private side a datetime. + return convert_to_datetime(cast(PublicLight, obj).last_motion) + + def _get_uptime(obj: ProtectDeviceModel) -> datetime | None: if obj.up_since is None: return None @@ -508,7 +518,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="last_motion", + ufp_public_value_fn=_get_last_motion_public, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( @@ -516,14 +526,14 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="motion_sensitivity", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.pir_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription[Light]( key="light_motion", translation_key="light_mode", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f7f664c9d92c..54812f75882e 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -387,8 +387,8 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( key="status_light", translation_key="status_light", entity_category=EntityCategory.CONFIG, - ufp_value="light_device_settings.is_indicator_enabled", - ufp_set_method="set_status_light", + ufp_public_value="light_device_settings.is_indicator_enabled", + ufp_set_method="set_status_light_public", ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 933c0f9b6e8d..fc411102f607 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -5,18 +5,18 @@ import contextlib from functools import wraps from pathlib import Path import socket -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import CookieJar from uiprotect import ProtectApiClient from uiprotect.data import ( Bootstrap, ChannelQuality, - Light, LightModeEnableType, LightModeType, ProtectAdoptableDeviceModel, ) +from uiprotect.data.public_devices import PublicDeviceModel, PublicLight from uiprotect.exceptions import ClientError, NotAuthorized from homeassistant.const import ( @@ -95,15 +95,14 @@ def async_get_devices( @callback -def async_get_light_motion_current(obj: Light) -> str: - """Get light motion mode for Flood Light.""" - - if ( - obj.light_mode_settings.mode is LightModeType.MOTION - and obj.light_mode_settings.enable_at is LightModeEnableType.DARK - ): +def async_get_light_motion_current_public(obj: PublicDeviceModel) -> str | None: + """Get light motion mode for a Flood Light from the public API.""" + settings = cast(PublicLight, obj).light_mode_settings + if (mode := settings.mode) is None: + return None + if mode is LightModeType.MOTION and settings.enable_at is LightModeEnableType.DARK: return f"{LightModeType.MOTION.value}_dark" - return obj.light_mode_settings.mode.value + return mode.value @callback diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 6a82aa938201..4496af6cc7f2 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -15,7 +15,6 @@ from uiprotect.data import ( Sensor, SmartDetectObjectType, ) -from uiprotect.data.nvr import EventMetadata from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.websocket import WebsocketState @@ -51,9 +50,11 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, + setup_public_light, setup_public_sensor, ) @@ -118,6 +119,7 @@ async def test_binary_sensor_setup_light( ) -> None: """Test binary_sensor entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) @@ -729,47 +731,38 @@ async def test_binary_sensor_update_motion( async def test_binary_sensor_update_light_motion( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light, fixed_now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test binary_sensor motion entity.""" + """Test the light motion binary_sensor reads PIR motion from the public API.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) _, entity_id = await ids_from_device_description( hass, Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) + assert hass.states.get(entity_id).state == STATE_OFF - event_metadata = EventMetadata(light_id=light.id) - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.MOTION_LIGHT, - start=fixed_now - timedelta(seconds=1), - end=None, - score=100, - smart_detect_types=[], - smart_detect_event_ids=[], - metadata=event_metadata, - api=ufp.api, - ) - - new_light = light.model_copy() - new_light.is_pir_motion_detected = True - new_light.last_motion_event_id = event.id - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.api.bootstrap.events = {event.id: event} - ufp.ws_msg(mock_msg) + public = make_public_light(light, is_pir_motion_detected=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_ON + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_binary_sensor_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light binary_sensors are unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + for description in LIGHT_SENSOR_WRITE: + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_binary_sensor_update_mount_type_window( diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index ee094d61f422..b224e9ada64d 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -1,9 +1,8 @@ """Test the UniFi Protect light platform.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock -from uiprotect.data import Light -from uiprotect.data.types import LEDLevel +from uiprotect.data import DeviceState, Light from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -12,6 +11,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -22,7 +22,10 @@ from .utils import ( adopt_devices, assert_entity_counts, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) @@ -48,6 +51,7 @@ async def test_light_setup( ) -> None: """Test light entity setup.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -67,21 +71,15 @@ async def test_light_setup( async def test_light_update( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity update.""" + """Test the light reads on/off and brightness from a public WS update.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - new_light = light.model_copy() - new_light.is_light_on = True - new_light.light_device_settings.led_level = LEDLevel(3) - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = new_light - - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.ws_msg(mock_msg) + # Divergent public values (on, led_level 3 -> 128) prove the read path. + public = make_public_light(light, is_light_on=True, led_level=3) + ufp.devices_ws_subscription(public_device_ws_message(public)) await hass.async_block_till_done() state = hass.states.get("light.test_light") @@ -90,6 +88,56 @@ async def test_light_update( assert state.attributes[ATTR_BRIGHTNESS] == 128 +async def test_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """The light is unavailable without a public object.""" + + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_light_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """Light availability follows the public object's connection state.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + entity_id = "light.test_light" + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_light(light, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_light_brightness_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """A light without a public LED level reports no brightness.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + public = make_public_light(light, is_light_on=True) + public.light_device_settings.led_level = None + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] is None + + async def test_light_turn_on( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: @@ -98,6 +146,7 @@ async def test_light_turn_on( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -120,6 +169,7 @@ async def test_light_turn_on_with_brightness( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -146,6 +196,7 @@ async def test_light_turn_off( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index b1b8464d07f4..358413c4d995 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -167,8 +167,9 @@ async def test_number_setup_camera_missing_attr( async def test_number_light_sensitivity( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test sensitivity number entity for lights.""" + """Test sensitivity number entity for lights (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.NUMBER, 2, 2) @@ -180,7 +181,7 @@ async def test_number_light_sensitivity( ) with patch_ufp_method( - light, "set_sensitivity", new_callable=AsyncMock + light, "set_sensitivity_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "number", @@ -192,6 +193,39 @@ async def test_number_light_sensitivity( mock_method.assert_called_once_with(15.0) +async def test_number_light_sensitivity_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Sensitivity reads from the public object and refreshes on a public WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + + # A value the private fixture (45) would not produce proves the public source. + public = make_public_light(light, pir_sensitivity=30) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "30" + + +async def test_number_light_sensitivity_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated sensitivity number is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_number_light_duration( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index d270c1e38242..bb221d448cee 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -42,6 +42,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -56,9 +57,11 @@ from .utils import ( ids_from_device_description, init_entry, make_public_camera, + make_public_light, public_device_ws_message, remove_entities, setup_public_camera, + setup_public_light, ) @@ -113,6 +116,7 @@ async def test_select_setup_light( """Test select entity setup for light devices.""" light.light_mode_settings.enable_at = LightModeEnableType.DARK + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -415,8 +419,9 @@ async def test_select_update_doorbell_message( async def test_select_set_option_light_motion( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test Light Mode select.""" + """Test Light Mode select (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -425,7 +430,7 @@ async def test_select_set_option_light_motion( ) with patch_ufp_method( - light, "set_light_settings", new_callable=AsyncMock + light, "set_light_mode_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "select", @@ -437,6 +442,64 @@ async def test_select_set_option_light_motion( mock_method.assert_called_once_with(LightModeType.MANUAL, enable_at=None) +async def test_select_light_motion_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Light Mode select reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == "motion" + + # The private fixture is full-time motion; when_dark proves the public source. + public = make_public_light( + light, + light_mode=LightModeType.WHEN_DARK, + light_mode_enable_at=LightModeEnableType.DARK, + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "when_dark" + + +async def test_select_light_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light motion select is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_select_light_motion_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """A light that does not report a public mode leaves the select unknown.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + + public = make_public_light(light) + public.light_mode_settings.mode = None + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_select_set_option_light_camera( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, camera: Camera ) -> None: diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 499e66ff68c7..ab8e9c9fcc3f 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -11,11 +11,13 @@ from uiprotect.data import ( DeviceState, Event, EventType, + Light, ModelType, Sensor, ) from uiprotect.data.nvr import EventMetadata from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.utils import convert_to_datetime from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -23,6 +25,7 @@ from homeassistant.components.unifiprotect.sensor import ( ALL_DEVICES_SENSORS, CAMERA_DISABLED_SENSORS, CAMERA_SENSORS, + LIGHT_SENSORS, MOTION_TRIP_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, @@ -45,10 +48,12 @@ from .utils import ( enable_entity, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, reset_objects, + setup_public_light, setup_public_sensor, time_changed, ) @@ -704,6 +709,13 @@ async def test_aiport_no_sensor_entities( entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] + # Check no camera-specific sensors like motion detection exist + for entity in entities: + if entity.domain == Platform.SENSOR: + # Camera-specific sensors should not exist for AI Port + assert "detected_object" not in entity.unique_id + assert "last_motion" not in entity.unique_id + async def test_aiport_no_sensor_entities_on_runtime_adopt( hass: HomeAssistant, @@ -721,3 +733,43 @@ async def test_aiport_no_sensor_entities_on_runtime_adopt( entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] + + +async def test_sensor_light_last_motion_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The light's last-motion timestamp reads from the public API.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + # A value the private fixture would not produce proves the public source. + last_motion_ms = 1700000000000 + public = make_public_light(light, last_motion_ms=last_motion_ms) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert ( + hass.states.get(entity_id).state + == convert_to_datetime(last_motion_ms).isoformat() + ) + + +async def test_sensor_light_last_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated last-motion sensor is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 3d2ca313c0cd..a0b3f5a371d8 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -24,7 +24,14 @@ from homeassistant.components.unifiprotect.switch import ( PRIVACY_MODE_SWITCH, ProtectSwitchEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -37,7 +44,10 @@ from .utils import ( enable_entity, ids_from_device_description, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) CAMERA_SWITCHES_BASIC = [ @@ -139,6 +149,7 @@ async def test_switch_setup_light( ) -> None: """Test switch entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -269,6 +280,7 @@ async def test_switch_light_status( ) -> None: """Tests status light switch for lights.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -279,7 +291,7 @@ async def test_switch_light_status( ) with patch_ufp_method( - light, "set_status_light", new_callable=AsyncMock + light, "set_status_light_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -294,6 +306,40 @@ async def test_switch_light_status( mock_method.assert_called_with(False) +async def test_switch_light_status_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Status light switch reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_OFF + + # The private fixture has the indicator disabled; the public ON proves the source. + public = make_public_light(light, is_indicator_enabled=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch_light_status_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated status light switch is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_switch_camera_ssh( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: @@ -569,6 +615,7 @@ async def test_switch_turn_on_client_error( ) -> None: """Test switch turn on with ClientError raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -580,7 +627,7 @@ async def test_switch_turn_on_client_error( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=ClientError("Test error"), ), @@ -596,6 +643,7 @@ async def test_switch_turn_on_not_authorized( ) -> None: """Test switch turn on with NotAuthorized raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -607,7 +655,7 @@ async def test_switch_turn_on_not_authorized( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=NotAuthorized("Not authorized"), ), diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 218f6958dee5..4ae15eef9acf 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -15,6 +15,8 @@ from uiprotect.data import ( Event, EventType, Light, + LightModeEnableType, + LightModeType, ModelType, MountType, ProtectAdoptableDeviceModel, @@ -29,6 +31,7 @@ from uiprotect.data.public_devices import ( PublicHdrMode, PublicLight, PublicLightDeviceSettings, + PublicLightModeSettings, PublicSensor, PublicSensorLeakSettings, PublicSensorMotionSettingsRead, @@ -335,29 +338,65 @@ def make_public_light( light: Light, *, state: DeviceState | None = None, + is_light_on: bool | None = None, + is_dark: bool | None = None, + is_pir_motion_detected: bool | None = None, + last_motion_ms: int | None = None, + led_level: int | None = None, pir_duration_ms: int | None = None, + pir_sensitivity: int | None = None, + is_indicator_enabled: bool | None = None, + light_mode: LightModeType | None = None, + light_mode_enable_at: LightModeEnableType | None = None, ) -> Mock: - """Build a public-API light for the migrated PIR auto-shutoff duration number. + """Build a public-API light mirroring the private fixture's migrated fields. - ``light_device_settings`` mirrors the private fixture (the public API reports - ``pir_duration`` in milliseconds); ``pir_duration_ms`` overrides it so a test - can assert a value the private object would not produce. + Every field the FloodLight entities read over the public API is mirrored from + the private light; each ``*`` override lets a test set a value the private + object would not produce, proving the entity reads the public source. The + public API reports ``pir_duration`` and ``last_motion`` in milliseconds. """ lds = light.light_device_settings + lms = light.light_mode_settings public = Mock(spec=PublicLight) public.id = light.id public.mac = light.mac public.model = ModelType.LIGHT public.state = DeviceState[light.state.name] if state is None else state + public.is_light_on = light.is_light_on if is_light_on is None else is_light_on + public.is_dark = light.is_dark if is_dark is None else is_dark + public.is_pir_motion_detected = ( + light.is_pir_motion_detected + if is_pir_motion_detected is None + else is_pir_motion_detected + ) + if last_motion_ms is not None: + public.last_motion = last_motion_ms + elif light.last_motion is not None: + public.last_motion = round(light.last_motion.timestamp() * 1000) + else: + public.last_motion = None + public.light_mode_settings = PublicLightModeSettings( + mode=lms.mode if light_mode is None else light_mode, + enable_at=( + lms.enable_at if light_mode_enable_at is None else light_mode_enable_at + ), + ) public.light_device_settings = PublicLightDeviceSettings( - is_indicator_enabled=lds.is_indicator_enabled, - led_level=lds.led_level, + is_indicator_enabled=( + lds.is_indicator_enabled + if is_indicator_enabled is None + else is_indicator_enabled + ), + led_level=lds.led_level if led_level is None else led_level, pir_duration=( round(lds.pir_duration.total_seconds() * 1000) if pir_duration_ms is None else pir_duration_ms ), - pir_sensitivity=lds.pir_sensitivity, + pir_sensitivity=( + lds.pir_sensitivity if pir_sensitivity is None else pir_sensitivity + ), ) return public From a4a77abc18a4179e80e179b79e007ca1816c9364 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 15 Jul 2026 15:52:43 +0200 Subject: [PATCH 629/707] Restore KNX date, datetime, time, number, text and select states (#176413) Co-authored-by: Claude Opus 4.8 --- homeassistant/components/knx/date.py | 6 ++-- homeassistant/components/knx/datetime.py | 6 ++-- homeassistant/components/knx/number.py | 6 ++-- homeassistant/components/knx/select.py | 4 +-- homeassistant/components/knx/text.py | 4 +-- homeassistant/components/knx/time.py | 6 ++-- tests/components/knx/test_date.py | 33 +++++++++++++++++- tests/components/knx/test_datetime.py | 36 +++++++++++++++++++- tests/components/knx/test_number.py | 43 +++++++++++++++++++++++- tests/components/knx/test_select.py | 34 +++++++++++++++++++ tests/components/knx/test_text.py | 36 +++++++++++++++++++- tests/components/knx/test_time.py | 33 +++++++++++++++++- 12 files changed, 220 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index e84c9f2c7941..ec7c7cb2c220 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -79,10 +79,8 @@ class _KNXDate(DateEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDate.from_date( dt_date.fromisoformat(last_state.state) ) diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 91c81eba8f15..04674fa4cd28 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -80,10 +80,8 @@ class _KNXDateTime(DateTimeEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDateTime.from_datetime( datetime.fromisoformat(last_state.state).astimezone( dt_util.get_default_time_zone() diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index db59b9527eb5..b6102c805e86 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -82,10 +82,8 @@ class _KnxNumber(RestoreNumber): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if ( - not self._device.sensor_value.readable - and (last_state := await self.async_get_last_state()) - and (last_number_data := await self.async_get_last_number_data()) + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() ): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.sensor_value.value = last_number_data.native_value diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index f67465291dc5..b9079ac9ee30 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -83,9 +83,7 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if ( last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) and (option := self._option_payloads.get(last_state.state)) is not None diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index d96c41dc45ac..c42e1863e174 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -81,9 +81,7 @@ class _KnxText(TextEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = last_state.state diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 99e16b0a2beb..dd42a23cf397 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -79,10 +79,8 @@ class _KNXTime(TimeEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XknxTime.from_time( dt_time.fromisoformat(last_state.state) ) diff --git a/tests/components/knx/test_date.py b/tests/components/knx/test_date.py index 98e35d16db0f..5e25c7f1651c 100644 --- a/tests/components/knx/test_date.py +++ b/tests/components/knx/test_date.py @@ -5,7 +5,11 @@ from homeassistant.components.date import ( DOMAIN as DATE_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -92,6 +96,33 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2024-02-24" +async def test_date_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("date.test", "2023-07-24") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("date.test") + assert state.state == "2023-07-24" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x18, 0x02, 0x18)) + state = hass.states.get("date.test") + assert state.state == "2024-02-24" + + async def test_date_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py index b79e8abe8a63..e8107b068ef8 100644 --- a/tests/components/knx/test_datetime.py +++ b/tests/components/knx/test_datetime.py @@ -5,7 +5,11 @@ from homeassistant.components.datetime import ( DOMAIN as DATETIME_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateTimeSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -96,6 +100,36 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2020-01-01T18:04:05+00:00" +async def test_datetime_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX datetime with state_address restores state until bus read completes.""" + await hass.config.async_set_time_zone("Europe/Vienna") + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("datetime.test", "2022-03-03T03:04:05+00:00") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateTimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, (0x78, 0x01, 0x01, 0x73, 0x04, 0x05, 0x20, 0x80) + ) + state = hass.states.get("datetime.test") + assert state.state == "2020-01-01T18:04:05+00:00" + + async def test_datetime_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index f4b8856cabe4..e00e3dfe4c44 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -5,7 +5,11 @@ from typing import Any import pytest -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import NumberSchema from homeassistant.const import CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, State @@ -112,6 +116,43 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) assert state.state == "9000.96" +async def test_number_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX number with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + + RESTORE_DATA = { + "native_max_value": None, # Ignored by KNX number + "native_min_value": None, # Ignored by KNX number + "native_step": None, # Ignored by KNX number + "native_unit_of_measurement": None, # Ignored by KNX number + "native_value": 160.0, + } + mock_restore_cache_with_extra_data( + hass, ((State("number.test", "abc"), RESTORE_DATA),) + ) + + await knx.setup_integration( + { + NumberSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_TYPE: "illuminance", + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("number.test") + assert state.state == "160.0" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x4E, 0xDE)) + state = hass.states.get("number.test") + assert state.state == "9000.96" + + @pytest.mark.parametrize( "attribute_config", [ diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index b53dfae2658b..3bec54abed1b 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -125,6 +125,40 @@ async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit) -> Non await knx.assert_no_telegram() +async def test_select_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX select with state_address restores state until bus read completes.""" + _options = [ + {CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + ] + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("select.test", "Control - On") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + SelectSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_PAYLOAD_LENGTH: 0, + SelectSchema.CONF_OPTIONS: _options, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("select.test") + assert state.state == "Control - On" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, 0b10) + state = hass.states.get("select.test") + assert state.state == "Control - Off" + + async def test_select_dpt_20_103_all_options( hass: HomeAssistant, knx: KNXTestKit ) -> None: diff --git a/tests/components/knx/test_text.py b/tests/components/knx/test_text.py index b2222ff025b8..7eb25399db5f 100644 --- a/tests/components/knx/test_text.py +++ b/tests/components/knx/test_text.py @@ -1,6 +1,10 @@ """Test KNX number.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TextSchema from homeassistant.components.text import TextMode from homeassistant.const import CONF_NAME, Platform @@ -103,6 +107,36 @@ async def test_text_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "hallo" +async def test_text_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX text with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("text.test", "test test") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TextSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("text.test") + assert state.state == "test test" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, + (0x68, 0x61, 0x6C, 0x6C, 0x6F, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0), + ) + state = hass.states.get("text.test") + assert state.state == "hallo" + + async def test_text_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_time.py b/tests/components/knx/test_time.py index 08a4edff70f4..19e069706d3d 100644 --- a/tests/components/knx/test_time.py +++ b/tests/components/knx/test_time.py @@ -1,6 +1,10 @@ """Test KNX time.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TimeSchema from homeassistant.components.time import ( ATTR_TIME, @@ -92,6 +96,33 @@ async def test_time_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "12:00:00" +async def test_time_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX time with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("time.test", "01:02:03") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("time.test") + assert state.state == "01:02:03" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x0C, 0x00, 0x00)) + state = hass.states.get("time.test") + assert state.state == "12:00:00" + + async def test_time_ui_create( hass: HomeAssistant, knx: KNXTestKit, From 8806b747376e2f4f4938c0a980b169d757086bb0 Mon Sep 17 00:00:00 2001 From: Pieter Smit Date: Wed, 15 Jul 2026 16:01:32 +0200 Subject: [PATCH 630/707] Poll faster around the delivery in Picnic (#176292) --- homeassistant/components/picnic/const.py | 7 + .../components/picnic/coordinator.py | 57 ++++++- tests/components/picnic/conftest.py | 48 +++++- tests/components/picnic/test_coordinator.py | 159 +++++++++++++++++- 4 files changed, 265 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index b913092771fe..98330e34a0b2 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,5 +1,7 @@ """Constants for the Picnic integration.""" +from datetime import timedelta + DOMAIN = "picnic" SERVICE_ADD_PRODUCT_TO_CART = "add_product" @@ -18,6 +20,11 @@ SLOT_DATA = "slot_data" NEXT_DELIVERY_DATA = "next_delivery_data" LAST_ORDER_DATA = "last_order_data" +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30) +DELIVERY_UPDATE_INTERVAL = timedelta(minutes=1) +DELIVERY_WINDOW_LEAD_TIME = timedelta(minutes=30) +DELIVERY_WINDOW_LAG_TIME = timedelta(hours=2) + SENSOR_CART_ITEMS_COUNT = "cart_items_count" SENSOR_CART_TOTAL_PRICE = "cart_total_price" SENSOR_SELECTED_SLOT_START = "selected_slot_start" diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 43aca27b3bf2..8cc2b21a5be5 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -15,8 +15,19 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util -from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA +from .const import ( + ADDRESS, + CART_DATA, + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, + DELIVERY_WINDOW_LAG_TIME, + DELIVERY_WINDOW_LEAD_TIME, + LAST_ORDER_DATA, + NEXT_DELIVERY_DATA, + SLOT_DATA, +) type PicnicConfigEntry = ConfigEntry[PicnicUpdateCoordinator] @@ -42,12 +53,18 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): logger, config_entry=config_entry, name="Picnic coordinator", - update_interval=timedelta(minutes=30), + update_interval=DEFAULT_UPDATE_INTERVAL, ) @override async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" + # Recompute up front so failed refreshes also relax the cadence + if self.data: + self.update_interval = self._get_update_interval( + self.data.get(NEXT_DELIVERY_DATA) + ) + try: async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) @@ -63,9 +80,45 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): "Timeout while connecting to the Picnic API", retry_after=120 ) from error + self.update_interval = self._get_update_interval(data.get(NEXT_DELIVERY_DATA)) + # Return the fetched data return data + @staticmethod + def _get_update_interval(next_delivery: dict | None) -> timedelta: + """Poll faster around the delivery so the live ETA is picked up in time.""" + if not next_delivery: + return DEFAULT_UPDATE_INTERVAL + + eta = next_delivery.get("eta") + slot = next_delivery.get("slot") + + start = end = None + if eta: + start = dt_util.parse_datetime(str(eta.get("start"))) + end = dt_util.parse_datetime(str(eta.get("end"))) + if (start is None or end is None) and slot: + start = dt_util.parse_datetime(str(slot.get("window_start"))) + end = dt_util.parse_datetime(str(slot.get("window_end"))) + + if start is None or end is None: + return DEFAULT_UPDATE_INTERVAL + + now = dt_util.utcnow() + window_start = start - DELIVERY_WINDOW_LEAD_TIME + + if window_start <= now <= end + DELIVERY_WINDOW_LAG_TIME: + return DELIVERY_UPDATE_INTERVAL + + if now < window_start: + return max( + DELIVERY_UPDATE_INTERVAL, + min(DEFAULT_UPDATE_INTERVAL, window_start - now), + ) + + return DEFAULT_UPDATE_INTERVAL + def fetch_data(self): """Fetch data from the Picnic API. diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index 569d65df3872..fac10ec491bf 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -1,6 +1,7 @@ """Conftest for Picnic tests.""" from collections.abc import Awaitable, Callable +from datetime import timedelta import json from unittest.mock import MagicMock, patch @@ -9,12 +10,18 @@ import pytest from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, load_fixture from tests.typing import WebSocketGenerator ENTITY_ID = "todo.mock_title_shopping_cart" +SetupDeliveryFixture = Callable[ + [str, tuple[timedelta, timedelta] | None, tuple[timedelta, timedelta]], + Awaitable[dict], +] + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -37,13 +44,48 @@ def mock_picnic_api(): client.session.auth_token = "3q29fpwhulzes" client.get_cart.return_value = json.loads(load_fixture("picnic/cart.json")) client.get_user.return_value = json.loads(load_fixture("picnic/user.json")) - client.get_deliveries.return_value = json.loads( - load_fixture("picnic/delivery.json") - ) + client.get_deliveries.return_value = [ + json.loads(load_fixture("picnic/delivery.json")) + ] client.get_delivery_position.return_value = {} yield client +@pytest.fixture +def setup_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> SetupDeliveryFixture: + """Return a factory to set up the integration with the delivery in a given state.""" + + async def _setup( + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + ) -> dict: + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = status + delivery["delivery_time"] = None + # eta2 is the API's field name for the route-planning ETA + delivery["eta2"] = eta2 and { + "start": (dt_util.utcnow() + eta2[0]).isoformat(), + "end": (dt_util.utcnow() + eta2[1]).isoformat(), + } + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + slot_window[0] + ).isoformat() + delivery["slot"]["window_end"] = (dt_util.utcnow() + slot_window[1]).isoformat() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return delivery + + return _setup + + @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_picnic_api: MagicMock diff --git a/tests/components/picnic/test_coordinator.py b/tests/components/picnic/test_coordinator.py index 9279ec07b497..209fcedd29f8 100644 --- a/tests/components/picnic/test_coordinator.py +++ b/tests/components/picnic/test_coordinator.py @@ -1,11 +1,22 @@ """Tests for the Picnic coordinator.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.picnic.const import ( + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from .conftest import SetupDeliveryFixture + +from tests.common import MockConfigEntry, async_fire_time_changed async def test_timeout_failed_with_retry( @@ -21,3 +32,149 @@ async def test_timeout_failed_with_retry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("status", "eta2", "slot_window", "expected_interval"), + [ + pytest.param( + "COMPLETED", + None, + (timedelta(hours=-2), timedelta(hours=-1)), + DEFAULT_UPDATE_INTERVAL, + id="no_undelivered_order", + ), + pytest.param( + "CURRENT", + (timedelta(days=2), timedelta(days=2, hours=1)), + (timedelta(days=2), timedelta(days=2, hours=1)), + DEFAULT_UPDATE_INTERVAL, + id="delivery_days_away", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + DELIVERY_UPDATE_INTERVAL, + id="delivery_under_way", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=40), timedelta(minutes=60)), + (timedelta(minutes=40), timedelta(minutes=60)), + timedelta(minutes=10), + id="next_poll_capped_at_window_start", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + DELIVERY_UPDATE_INTERVAL, + id="next_poll_never_sooner_than_delivery_interval", + ), + pytest.param( + "CURRENT", + (timedelta(hours=-4), timedelta(hours=-3)), + (timedelta(hours=-4), timedelta(hours=-3)), + DEFAULT_UPDATE_INTERVAL, + id="long_past_window_still_current", + ), + pytest.param( + "CURRENT", + None, + (timedelta(minutes=10), timedelta(minutes=70)), + DELIVERY_UPDATE_INTERVAL, + id="slot_window_fallback_without_eta", + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_interval( + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + expected_interval: timedelta, +) -> None: + """Test the update interval for the various delivery states.""" + await setup_delivery(status, eta2, slot_window) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == expected_interval + + +@pytest.mark.usefixtures("freezer") +async def test_update_interval_with_malformed_eta( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> None: + """Test that a malformed ETA falls back to the slot window.""" + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = "CURRENT" + delivery["delivery_time"] = None + delivery["eta2"] = {"start": "malformed", "end": "malformed"} + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + timedelta(minutes=10) + ).isoformat() + delivery["slot"]["window_end"] = ( + dt_util.utcnow() + timedelta(minutes=70) + ).isoformat() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_after_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the update interval returns to the default once delivered.""" + delivery = await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + delivery["status"] = "COMPLETED" + freezer.tick(DELIVERY_UPDATE_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_when_refresh_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that failed refreshes still relax the interval past the window.""" + await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + mock_picnic_api.get_cart.return_value = None + freezer.tick(timedelta(hours=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.last_update_success is False + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL From 2f97d5b9505e629895db17b1fff5e7e714bf548c Mon Sep 17 00:00:00 2001 From: Niels Date: Wed, 15 Jul 2026 16:10:27 +0200 Subject: [PATCH 631/707] Add vibration triggers (#176408) --- CODEOWNERS | 2 + homeassistant/bootstrap.py | 1 + .../components/vibration/__init__.py | 15 ++ homeassistant/components/vibration/icons.json | 10 + .../components/vibration/manifest.json | 8 + .../components/vibration/strings.json | 33 +++ homeassistant/components/vibration/trigger.py | 24 +++ .../components/vibration/triggers.yaml | 26 +++ script/hassfest/manifest.py | 1 + script/hassfest/quality_scale.py | 1 + tests/components/vibration/__init__.py | 1 + tests/components/vibration/test_trigger.py | 200 ++++++++++++++++++ tests/snapshots/test_bootstrap.ambr | 2 + 13 files changed, 324 insertions(+) create mode 100644 homeassistant/components/vibration/__init__.py create mode 100644 homeassistant/components/vibration/icons.json create mode 100644 homeassistant/components/vibration/manifest.json create mode 100644 homeassistant/components/vibration/strings.json create mode 100644 homeassistant/components/vibration/trigger.py create mode 100644 homeassistant/components/vibration/triggers.yaml create mode 100644 tests/components/vibration/__init__.py create mode 100644 tests/components/vibration/test_trigger.py diff --git a/CODEOWNERS b/CODEOWNERS index ccb837bedb74..93d8fcdbefdb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1967,6 +1967,8 @@ CLAUDE.md @home-assistant/core /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven +/homeassistant/components/vibration/ @home-assistant/core +/tests/components/vibration/ @home-assistant/core /homeassistant/components/vicare/ @CFenner @lackas /tests/components/vicare/ @CFenner @lackas /homeassistant/components/victron_ble/ @rajlaud diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 5313392d73a9..0c606c38d080 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -264,6 +264,7 @@ DEFAULT_INTEGRATIONS = { "occupancy", "power", "temperature", + "vibration", "window", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { diff --git a/homeassistant/components/vibration/__init__.py b/homeassistant/components/vibration/__init__.py new file mode 100644 index 000000000000..b361746282f3 --- /dev/null +++ b/homeassistant/components/vibration/__init__.py @@ -0,0 +1,15 @@ +"""Integration for vibration triggers.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "vibration" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/vibration/icons.json b/homeassistant/components/vibration/icons.json new file mode 100644 index 000000000000..009711fd1655 --- /dev/null +++ b/homeassistant/components/vibration/icons.json @@ -0,0 +1,10 @@ +{ + "triggers": { + "cleared": { + "trigger": "mdi:vibrate-off" + }, + "detected": { + "trigger": "mdi:vibrate" + } + } +} diff --git a/homeassistant/components/vibration/manifest.json b/homeassistant/components/vibration/manifest.json new file mode 100644 index 000000000000..e875b7c6c583 --- /dev/null +++ b/homeassistant/components/vibration/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "vibration", + "name": "Vibration", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/vibration", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/vibration/strings.json b/homeassistant/components/vibration/strings.json new file mode 100644 index 000000000000..3e7d47b8dbfd --- /dev/null +++ b/homeassistant/components/vibration/strings.json @@ -0,0 +1,33 @@ +{ + "common": { + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "title": "Vibration", + "triggers": { + "cleared": { + "description": "Triggers when one or more vibration sensors stop detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration cleared" + }, + "detected": { + "description": "Triggers when one or more vibration sensors start detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration detected" + } + } +} diff --git a/homeassistant/components/vibration/trigger.py b/homeassistant/components/vibration/trigger.py new file mode 100644 index 000000000000..a23a62401660 --- /dev/null +++ b/homeassistant/components/vibration/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_ON), + "cleared": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for vibration.""" + return TRIGGERS diff --git a/homeassistant/components/vibration/triggers.yaml b/homeassistant/components/vibration/triggers.yaml new file mode 100644 index 000000000000..0957393172c3 --- /dev/null +++ b/homeassistant/components/vibration/triggers.yaml @@ -0,0 +1,26 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: each + selector: + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: + +detected: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +cleared: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9ca4987719f6..fb23e8b3c957 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -126,6 +126,7 @@ NO_IOT_CLASS = [ "temperature", "timer", "trace", + "vibration", "web_rtc", "webhook", "websocket_api", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 0a872ddbb8d5..b04a7c801e12 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2076,6 +2076,7 @@ NO_QUALITY_SCALE = [ "timer", "trace", "usage_prediction", + "vibration", "web_rtc", "webhook", "websocket_api", diff --git a/tests/components/vibration/__init__.py b/tests/components/vibration/__init__.py new file mode 100644 index 000000000000..7bb5d6ed877e --- /dev/null +++ b/tests/components/vibration/__init__.py @@ -0,0 +1 @@ +"""Tests for the vibration integration.""" diff --git a/tests/components/vibration/test_trigger.py b/tests/components/vibration/test_trigger.py new file mode 100644 index 000000000000..98e355952dec --- /dev/null +++ b/tests/components/vibration/test_trigger.py @@ -0,0 +1,200 @@ +"""Test vibration trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_all, + assert_trigger_behavior_each, + assert_trigger_behavior_first, + assert_trigger_options_supported, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("trigger_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.detected", {}, True, True), + ("vibration.cleared", {}, True, True), + ], +) +async def test_vibration_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_each( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires for binary_sensor entities with device_class vibration.""" + await assert_trigger_behavior_each( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_first( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires on the first binary_sensor state change.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires when all binary_sensors have changed state.""" + await assert_trigger_behavior_all( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index 561c4a060845..9b296dffe0df 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -100,6 +100,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather', @@ -209,6 +210,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather', From d52901192c81d58e5f20710841c6dbfbb4899ff5 Mon Sep 17 00:00:00 2001 From: Niels Date: Wed, 15 Jul 2026 16:11:42 +0200 Subject: [PATCH 632/707] Add moon triggers (#176411) --- homeassistant/components/moon/const.py | 2 + homeassistant/components/moon/helpers.py | 48 +++++++++ homeassistant/components/moon/icons.json | 5 + homeassistant/components/moon/sensor.py | 44 +-------- homeassistant/components/moon/strings.json | 29 +++++- homeassistant/components/moon/trigger.py | 88 +++++++++++++++++ homeassistant/components/moon/triggers.yaml | 18 ++++ tests/components/moon/test_sensor.py | 4 +- tests/components/moon/test_trigger.py | 103 ++++++++++++++++++++ 9 files changed, 297 insertions(+), 44 deletions(-) create mode 100644 homeassistant/components/moon/helpers.py create mode 100644 homeassistant/components/moon/trigger.py create mode 100644 homeassistant/components/moon/triggers.yaml create mode 100644 tests/components/moon/test_trigger.py diff --git a/homeassistant/components/moon/const.py b/homeassistant/components/moon/const.py index 3e926b4ff3e8..f51f80431804 100644 --- a/homeassistant/components/moon/const.py +++ b/homeassistant/components/moon/const.py @@ -8,3 +8,5 @@ DOMAIN: Final = "moon" PLATFORMS: Final = [Platform.SENSOR] DEFAULT_NAME: Final = "Moon" + +CONF_PHASE: Final = "phase" diff --git a/homeassistant/components/moon/helpers.py b/homeassistant/components/moon/helpers.py new file mode 100644 index 000000000000..dbf3b7907b3d --- /dev/null +++ b/homeassistant/components/moon/helpers.py @@ -0,0 +1,48 @@ +"""Helpers for moon phases.""" + +from astral import moon + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +STATE_FIRST_QUARTER = "first_quarter" +STATE_FULL_MOON = "full_moon" +STATE_LAST_QUARTER = "last_quarter" +STATE_NEW_MOON = "new_moon" +STATE_WANING_CRESCENT = "waning_crescent" +STATE_WANING_GIBBOUS = "waning_gibbous" +STATE_WAXING_CRESCENT = "waxing_crescent" +STATE_WAXING_GIBBOUS = "waxing_gibbous" + +# The eight moon phases in chronological order (new moon to waning crescent). +MOON_PHASES: tuple[str, ...] = ( + STATE_NEW_MOON, + STATE_WAXING_CRESCENT, + STATE_FIRST_QUARTER, + STATE_WAXING_GIBBOUS, + STATE_FULL_MOON, + STATE_WANING_GIBBOUS, + STATE_LAST_QUARTER, + STATE_WANING_CRESCENT, +) + + +@callback +def moon_phase() -> str: + """Return the current moon phase.""" + value: float = moon.phase(dt_util.now().date()) + if value < 0.5 or value > 27.5: + return STATE_NEW_MOON + if value < 6.5: + return STATE_WAXING_CRESCENT + if value < 7.5: + return STATE_FIRST_QUARTER + if value < 13.5: + return STATE_WAXING_GIBBOUS + if value < 14.5: + return STATE_FULL_MOON + if value < 20.5: + return STATE_WANING_GIBBOUS + if value < 21.5: + return STATE_LAST_QUARTER + return STATE_WANING_CRESCENT diff --git a/homeassistant/components/moon/icons.json b/homeassistant/components/moon/icons.json index 77c578c8f0d8..288925f28be3 100644 --- a/homeassistant/components/moon/icons.json +++ b/homeassistant/components/moon/icons.json @@ -15,5 +15,10 @@ } } } + }, + "triggers": { + "phase_changed": { + "trigger": "mdi:moon-waning-crescent" + } } } diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 3f7f25eb8149..c20a0a392dc6 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -1,24 +1,13 @@ """Support for tracking the moon phases.""" -from astral import moon - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util from .const import DOMAIN - -STATE_FIRST_QUARTER = "first_quarter" -STATE_FULL_MOON = "full_moon" -STATE_LAST_QUARTER = "last_quarter" -STATE_NEW_MOON = "new_moon" -STATE_WANING_CRESCENT = "waning_crescent" -STATE_WANING_GIBBOUS = "waning_gibbous" -STATE_WAXING_CRESCENT = "waxing_crescent" -STATE_WAXING_GIBBOUS = "waxing_gibbous" +from .helpers import MOON_PHASES, moon_phase async def async_setup_entry( @@ -35,16 +24,7 @@ class MoonSensorEntity(SensorEntity): _attr_has_entity_name = True _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [ - STATE_NEW_MOON, - STATE_WAXING_CRESCENT, - STATE_FIRST_QUARTER, - STATE_WAXING_GIBBOUS, - STATE_FULL_MOON, - STATE_WANING_GIBBOUS, - STATE_LAST_QUARTER, - STATE_WANING_CRESCENT, - ] + _attr_options = list(MOON_PHASES) _attr_translation_key = "phase" def __init__(self, entry: ConfigEntry) -> None: @@ -58,22 +38,4 @@ class MoonSensorEntity(SensorEntity): async def async_update(self) -> None: """Get the time and updates the states.""" - today = dt_util.now().date() - state = moon.phase(today) - - if state < 0.5 or state > 27.5: - self._attr_native_value = STATE_NEW_MOON - elif state < 6.5: - self._attr_native_value = STATE_WAXING_CRESCENT - elif state < 7.5: - self._attr_native_value = STATE_FIRST_QUARTER - elif state < 13.5: - self._attr_native_value = STATE_WAXING_GIBBOUS - elif state < 14.5: - self._attr_native_value = STATE_FULL_MOON - elif state < 20.5: - self._attr_native_value = STATE_WANING_GIBBOUS - elif state < 21.5: - self._attr_native_value = STATE_LAST_QUARTER - else: - self._attr_native_value = STATE_WANING_CRESCENT + self._attr_native_value = moon_phase() diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json index 8048f344c7b1..65baaed8766a 100644 --- a/homeassistant/components/moon/strings.json +++ b/homeassistant/components/moon/strings.json @@ -37,5 +37,32 @@ } } }, - "title": "Moon" + "selector": { + "phase": { + "options": { + "any": "Any", + "first_quarter": "[%key:component::moon::entity::sensor::phase::state::first_quarter%]", + "full_moon": "[%key:component::moon::entity::sensor::phase::state::full_moon%]", + "last_quarter": "[%key:component::moon::entity::sensor::phase::state::last_quarter%]", + "new_moon": "[%key:component::moon::entity::sensor::phase::state::new_moon%]", + "waning_crescent": "[%key:component::moon::entity::sensor::phase::state::waning_crescent%]", + "waning_gibbous": "[%key:component::moon::entity::sensor::phase::state::waning_gibbous%]", + "waxing_crescent": "[%key:component::moon::entity::sensor::phase::state::waxing_crescent%]", + "waxing_gibbous": "[%key:component::moon::entity::sensor::phase::state::waxing_gibbous%]" + } + } + }, + "title": "Moon", + "triggers": { + "phase_changed": { + "description": "Triggers when the moon enters a new phase.", + "fields": { + "phase": { + "description": "Limit the trigger to a specific moon phase, or leave as Any to trigger on every phase change.", + "name": "Phase" + } + }, + "name": "Moon phase changed" + } + } } diff --git a/homeassistant/components/moon/trigger.py b/homeassistant/components/moon/trigger.py new file mode 100644 index 000000000000..174436020f9a --- /dev/null +++ b/homeassistant/components/moon/trigger.py @@ -0,0 +1,88 @@ +"""Provides triggers for the moon.""" + +from datetime import datetime +from typing import cast, override + +import voluptuous as vol + +from homeassistant.const import CONF_OPTIONS +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_change +from homeassistant.helpers.trigger import ( + Trigger, + TriggerActionRunner, + TriggerConfig, + TriggerNotTriggeredReporter, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_PHASE +from .helpers import MOON_PHASES, moon_phase + +PHASE_ANY = "any" + +_PHASE_CHANGED_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default=dict): { + vol.Optional(CONF_PHASE, default=PHASE_ANY): vol.In( + [PHASE_ANY, *MOON_PHASES] + ), + } + } +) + + +class MoonPhaseChangedTrigger(Trigger): + """Trigger that fires when the moon enters a new phase.""" + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, _PHASE_CHANGED_TRIGGER_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the trigger.""" + super().__init__(hass, config) + options = config.options or {} + self._phase: str = options[CONF_PHASE] + + @override + async def async_attach_runner( + self, + run_action: TriggerActionRunner, + did_not_trigger: TriggerNotTriggeredReporter | None = None, + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" + last_phase = moon_phase() + + @callback + def check_phase(_now: datetime) -> None: + nonlocal last_phase + current_phase = moon_phase() + if current_phase == last_phase: + return + previous_phase = last_phase + last_phase = current_phase + if self._phase in (PHASE_ANY, current_phase): + run_action( + {"phase": current_phase, "previous_phase": previous_phase}, + "moon phase changed", + ) + + # The binned phase can only change when the local date rolls over. + return async_track_time_change( + self._hass, check_phase, hour=0, minute=0, second=0 + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + "phase_changed": MoonPhaseChangedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for the moon.""" + return TRIGGERS diff --git a/homeassistant/components/moon/triggers.yaml b/homeassistant/components/moon/triggers.yaml new file mode 100644 index 000000000000..7a6457d452e3 --- /dev/null +++ b/homeassistant/components/moon/triggers.yaml @@ -0,0 +1,18 @@ +phase_changed: + fields: + phase: + required: true + default: any + selector: + select: + translation_key: phase + options: + - any + - new_moon + - waxing_crescent + - first_quarter + - waxing_gibbous + - full_moon + - waning_gibbous + - last_quarter + - waning_crescent diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py index 2a353bb60ba5..149e423be91d 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.moon.sensor import ( +from homeassistant.components.moon.helpers import ( STATE_FIRST_QUARTER, STATE_FULL_MOON, STATE_LAST_QUARTER, @@ -47,7 +47,7 @@ async def test_moon_day( mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.moon.sensor.moon.phase", return_value=moon_value + "homeassistant.components.moon.helpers.moon.phase", return_value=moon_value ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/moon/test_trigger.py b/tests/components/moon/test_trigger.py new file mode 100644 index 000000000000..266fb2dbfa34 --- /dev/null +++ b/tests/components/moon/test_trigger.py @@ -0,0 +1,103 @@ +"""Tests for the moon triggers.""" + +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + +_PHASE = "homeassistant.components.moon.helpers.moon.phase" + + +@pytest.fixture(autouse=True) +async def setup_moon(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Set up the moon integration so its trigger platform is available.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def _arm(hass: HomeAssistant, options: dict[str, Any] | None = None) -> None: + """Set up an automation with the moon phase_changed trigger.""" + trigger: dict[str, Any] = {"platform": "moon.phase_changed"} + if options is not None: + trigger["options"] = options + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": trigger, + "action": { + "service": "test.automation", + "data_template": { + "phase": "{{ trigger.phase }}", + "previous_phase": "{{ trigger.previous_phase }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + +def _next_local_midnight() -> datetime: + """Return the next local midnight, when the phase trigger re-evaluates.""" + return dt_util.start_of_local_day() + timedelta(days=1) + + +async def test_phase_changed_fires_on_any_change( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger fires on every phase change when unfiltered.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass) + assert len(service_calls) == 0 + + with patch(_PHASE, return_value=14.0): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["phase"] == "full_moon" + assert service_calls[0].data["previous_phase"] == "new_moon" + + +async def test_phase_changed_ignores_same_phase( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger does not fire when the phase is unchanged.""" + with patch(_PHASE, return_value=14.0): + await _arm(hass) + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 0 + + +@pytest.mark.parametrize( + ("new_value", "expected_calls"), + [(14.0, 1), (5.0, 0)], +) +async def test_phase_changed_with_phase_filter( + hass: HomeAssistant, + service_calls: list[ServiceCall], + new_value: float, + expected_calls: int, +) -> None: + """Test the trigger only fires for the configured phase.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass, options={"phase": "full_moon"}) + + with patch(_PHASE, return_value=new_value): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == expected_calls From e902efe0e47e7989de2e16cb9cf5c72a026de524 Mon Sep 17 00:00:00 2001 From: Matthew Gibson <64029882+frogman85978@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:13:22 -0400 Subject: [PATCH 633/707] Added binary sensor for battery status and external power to the PTDevices integration (#169862) --- .../components/ptdevices/__init__.py | 1 + .../components/ptdevices/binary_sensor.py | 121 ++++++++++++++++++ .../components/ptdevices/strings.json | 5 + .../ptdevices/fixtures/ptdevices_level.json | 1 + .../snapshots/test_binary_sensor.ambr | 103 +++++++++++++++ .../ptdevices/test_binary_sensor.py | 95 ++++++++++++++ tests/components/ptdevices/test_sensor.py | 34 ++++- 7 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/ptdevices/binary_sensor.py create mode 100644 tests/components/ptdevices/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/ptdevices/test_binary_sensor.py diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py index 9a557749494e..00f8c28d8a86 100644 --- a/homeassistant/components/ptdevices/__init__.py +++ b/homeassistant/components/ptdevices/__init__.py @@ -11,6 +11,7 @@ from .const import DEFAULT_URL from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator _PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] diff --git a/homeassistant/components/ptdevices/binary_sensor.py b/homeassistant/components/ptdevices/binary_sensor.py new file mode 100644 index 000000000000..b3858200c171 --- /dev/null +++ b/homeassistant/components/ptdevices/binary_sensor.py @@ -0,0 +1,121 @@ +"""PTDevices Binary Sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator +from .entity import PTDevicesEntity + +PARALLEL_UPDATES = 0 + + +class PTDevicesBinarySensors(StrEnum): + """Store keys for PTDevices binary sensors.""" + + DEVICE_BATTERY_STATUS = "battery_status" + DEVICE_EXTERNAL_POWER = "external_power" + + +@dataclass(kw_only=True, frozen=True) +class PTDevicesBinarySensorEntityDescription(BinarySensorEntityDescription): + """Description for PTDevices binary sensor entities.""" + + is_on_fn: Callable[[dict[str, StateType]], bool | None] + + +BINARY_SENSOR_DESCRIPTIONS: tuple[PTDevicesBinarySensorEntityDescription, ...] = ( + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + translation_key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + None + if data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) + in (None, "unknown") + else data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) == "low" + ), + ), + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + translation_key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + bool(data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER)) + if data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER) is not None + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + async_add_entity: AddConfigEntryEntitiesCallback, +) -> None: + """Setup PTDevices binary sensors based on config entry.""" + coordinator = config_entry.runtime_data + + known_sensors: set[tuple[str, str]] = set() + + def _check_device() -> None: + for device_id in sorted(coordinator.data): + device = coordinator.data[device_id] + new_sensors = [ + sensor + for sensor in BINARY_SENSOR_DESCRIPTIONS + if sensor.key in device and (device_id, sensor.key) not in known_sensors + ] + if not new_sensors: + continue + known_sensors.update((device_id, sensor.key) for sensor in new_sensors) + async_add_entity( + PTDevicesBinarySensorEntity( + config_entry.runtime_data, sensor, device_id + ) + for sensor in new_sensors + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class PTDevicesBinarySensorEntity(PTDevicesEntity, BinarySensorEntity): + """Defines a PTDevices binary sensor.""" + + entity_description: PTDevicesBinarySensorEntityDescription + + def __init__( + self, + coordinator: PTDevicesCoordinator, + description: PTDevicesBinarySensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__( + coordinator, + description.key, + device_id, + ) + + self.entity_description = description + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + return self.entity_description.is_on_fn(self.device) diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json index 318c4fd1266d..9c5def4c87be 100644 --- a/homeassistant/components/ptdevices/strings.json +++ b/homeassistant/components/ptdevices/strings.json @@ -23,6 +23,11 @@ } }, "entity": { + "binary_sensor": { + "external_power": { + "name": "External power" + } + }, "sensor": { "battery_voltage": { "name": "Battery voltage" diff --git a/tests/components/ptdevices/fixtures/ptdevices_level.json b/tests/components/ptdevices/fixtures/ptdevices_level.json index c69e7049696d..402992d60a39 100644 --- a/tests/components/ptdevices/fixtures/ptdevices_level.json +++ b/tests/components/ptdevices/fixtures/ptdevices_level.json @@ -27,6 +27,7 @@ "battery_voltage": 5.69, "battery_status": "good", "battery_status_number": 1, + "external_power": 1, "volume_level": 2387.837753, "volume_level_oz": 80742.4, "max_volume": 1269, diff --git a/tests/components/ptdevices/snapshots/test_binary_sensor.ambr b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..354725850c9c --- /dev/null +++ b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.home_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.home_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_battery_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Home Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.home_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.home_external_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External power', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External power', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_external_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Home External power', + }), + 'context': , + 'entity_id': 'binary_sensor.home_external_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ptdevices/test_binary_sensor.py b/tests/components/ptdevices/test_binary_sensor.py new file mode 100644 index 000000000000..d6ddeeb16e7e --- /dev/null +++ b/tests/components/ptdevices/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test for PTDevices binary sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ptdevices.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.ptdevices._PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_ptdevices_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_battery_status_sensor_states( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test battery status binary sensor state recognition.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status is "normal" + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_OFF + + # Set the new battery status to low + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"][ + "battery_status" + ] = "low" + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery status is on (low) + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_binary_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new binary sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status exists + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state != STATE_UNKNOWN + + # Remove the battery_status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop( + "battery_status" + ) + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery_status is no longer present + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/ptdevices/test_sensor.py b/tests/components/ptdevices/test_sensor.py index 494fc632e555..97fc9ab63439 100644 --- a/tests/components/ptdevices/test_sensor.py +++ b/tests/components/ptdevices/test_sensor.py @@ -2,16 +2,18 @@ from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.ptdevices.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -29,3 +31,31 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the status exists + assert (state := hass.states.get("sensor.home_status")) + assert state.state != STATE_UNKNOWN + + # Remove the status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop("status") + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the status is no longer present + assert (state := hass.states.get("sensor.home_status")) + assert state.state == STATE_UNKNOWN From 0e3ed566f43827ca97e2a68e8dfd750679c997fb Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 15 Jul 2026 17:14:37 +0300 Subject: [PATCH 634/707] Promote Anthropic to Platinum (#176332) --- homeassistant/components/anthropic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 3153ab89fb31..398f3ccef507 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["anthropic==0.108.0"] } From 33de458bb5727b088cd142237797a339fc96d0d1 Mon Sep 17 00:00:00 2001 From: Matthew Dias Date: Wed, 15 Jul 2026 09:51:32 -0500 Subject: [PATCH 635/707] Refactor Whirlpool time sensor into a shared base class (#176464) Co-authored-by: Claude Opus 4.8 --- homeassistant/components/whirlpool/sensor.py | 70 ++++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index e7df831bb7d7..9bc5b73e7e9c 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -332,29 +332,29 @@ class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): - """Abstract base class for Whirlpool washer/dryer time sensors.""" +class WhirlpoolTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): + """Abstract base class for Whirlpool end-time timestamp sensors.""" _attr_should_poll = True - _appliance: Washer | Dryer - def __init__( - self, appliance: Washer | Dryer, description: SensorEntityDescription - ) -> None: - """Initialize the washer/dryer sensor.""" - super().__init__(appliance, unique_id_suffix=f"-{description.key}") - self.entity_description = description + def __init__(self, appliance: Appliance, unique_id_suffix: str) -> None: + """Initialize the time sensor.""" + super().__init__(appliance, unique_id_suffix=unique_id_suffix) self._running: bool | None = None self._value: datetime | None = None @abstractmethod - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: + """Return true if the timer/cycle is in a finished state.""" @abstractmethod - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: + """Return true if the timer/cycle is in a running state.""" + + @abstractmethod + def _get_seconds_remaining(self) -> int: + """Return the number of seconds remaining.""" @override async def async_added_to_hass(self) -> None: @@ -368,21 +368,19 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): """Update status of Whirlpool.""" await self._appliance.fetch_data() - @override @property + @override def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" now = utcnow() - if self._is_machine_state_finished() and self._running: + if self._is_finished() and self._running: self._running = False self._value = now - if self._is_machine_state_running(): + if self._is_running(): self._running = True - new_timestamp = now + timedelta( - seconds=self._appliance.get_time_remaining() - ) + new_timestamp = now + timedelta(seconds=self._get_seconds_remaining()) if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) @@ -391,45 +389,59 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): return self._value -class WasherTimeSensor(WasherDryerTimeSensorBase): +class WasherTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool washers.""" _appliance: Washer + def __init__(self, appliance: Washer, description: SensorEntityDescription) -> None: + """Initialize the washer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { WasherMachineState.Complete, WasherMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return ( self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle ) + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() -class DryerTimeSensor(WasherDryerTimeSensorBase): + +class DryerTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool dryers.""" _appliance: Dryer + def __init__(self, appliance: Dryer, description: SensorEntityDescription) -> None: + """Initialize the dryer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { DryerMachineState.Complete, DryerMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() + class WhirlpoolOvenCavitySensor(WhirlpoolOvenEntity, SensorEntity): """A class for Whirlpool oven cavity sensors.""" From 0b8fb3652506e3b01c61ddaa3ec15f0395f076d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2026 07:04:42 -1000 Subject: [PATCH 636/707] Provision ESPHome encryption keys over an encrypted zero-PSK connection (#176144) --- homeassistant/components/esphome/__init__.py | 46 +--- .../components/esphome/config_flow.py | 8 +- homeassistant/components/esphome/const.py | 3 + homeassistant/components/esphome/manager.py | 98 ++++++++- tests/components/esphome/conftest.py | 2 +- tests/components/esphome/test_manager.py | 205 ++++++++++++++++++ 6 files changed, 314 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5d329b61974f..b7c2eb8352e8 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,7 +2,7 @@ import logging -from aioesphomeapi import APIClient, APIConnectionError +from aioesphomeapi import APIConnectionError from homeassistant.components import zeroconf from homeassistant.components.bluetooth import async_remove_scanner @@ -11,13 +11,7 @@ from homeassistant.components.usb import ( USBDevice, async_register_serial_port_scanner, ) -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - EVENT_HOMEASSISTANT_STOP, - __version__ as ha_version, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import async_delete_issue @@ -29,15 +23,18 @@ from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN from .domain_data import DomainData from .encryption_key_storage import async_get_encryption_key_storage from .entry_data import ESPHomeConfigEntry, RuntimeEntryData -from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance +from .manager import ( + DEVICE_CONFLICT_ISSUE_FORMAT, + ESPHomeManager, + async_create_api_client, + cleanup_instance, +) from .websocket_api import async_setup as async_setup_websocket_api _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -CLIENT_INFO = f"Home Assistant {ha_version}" - @callback def _async_scan_serial_ports( @@ -90,20 +87,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool: """Set up the esphome component.""" host: str = entry.data[CONF_HOST] - port: int = entry.data[CONF_PORT] password: str | None = entry.data[CONF_PASSWORD] - noise_psk: str | None = entry.data.get(CONF_NOISE_PSK) zeroconf_instance = await zeroconf.async_get_instance(hass) - cli = APIClient( - host, - port, - password, - client_info=CLIENT_INFO, - zeroconf_instance=zeroconf_instance, - noise_psk=noise_psk, - timezone=hass.config.time_zone, + cli = async_create_api_client( + hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) ) domain_data = DomainData.get(hass) @@ -159,21 +148,10 @@ async def _async_clear_dynamic_encryption_key( if await storage.async_get_key(entry.unique_id) is None: return - host: str = entry.data[CONF_HOST] - port: int = entry.data[CONF_PORT] - password: str | None = entry.data[CONF_PASSWORD] - noise_psk: str | None = entry.data.get(CONF_NOISE_PSK) - zeroconf_instance = await zeroconf.async_get_instance(hass) - cli = APIClient( - host, - port, - password, - client_info=CLIENT_INFO, - zeroconf_instance=zeroconf_instance, - noise_psk=noise_psk, - timezone=hass.config.time_zone, + cli = async_create_api_client( + hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) ) try: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 71b99ced5fea..1d3488d3cd00 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -74,7 +74,11 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" ERROR_INVALID_PASSWORD_AUTH = "invalid_auth" _LOGGER = logging.getLogger(__name__) -ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" +# A deliberately wrong key (base64 of thirty two ASCII zero characters, not +# zero bytes) used only to elicit the server hello so the device name can be +# read. Not to be confused with aioesphomeapi.ZERO_NOISE_PSK, the well known +# all zeros provisioning key. +PROBE_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" DEFAULT_NAME = "ESPHome" _BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector( @@ -271,7 +275,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # to get the device name which will allow us to populate # the device name and hopefully get the encryption key # from the dashboard. - self._noise_psk = ZERO_NOISE_PSK + self._noise_psk = PROBE_NOISE_PSK response = await self.fetch_device_info() self._noise_psk = None diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index b10995ac27cc..508065b091c8 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final from awesomeversion import AwesomeVersion from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.const import __version__ as ha_version from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -12,6 +13,8 @@ if TYPE_CHECKING: DOMAIN = "esphome" +CLIENT_INFO = f"Home Assistant {ha_version}" + ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN) CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index a1428ddc702e..90e0760da46b 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -9,6 +9,7 @@ import struct from typing import TYPE_CHECKING, Any, Final, NamedTuple from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -34,7 +35,10 @@ import voluptuous as vol from homeassistant.components import bluetooth, tag, zeroconf from homeassistant.const import ( ATTR_DEVICE_ID, + CONF_HOST, CONF_MODE, + CONF_PASSWORD, + CONF_PORT, EVENT_HOMEASSISTANT_CLOSE, EVENT_LOGGING_CHANGED, Platform, @@ -77,6 +81,7 @@ from homeassistant.util.json import json_loads_object from .bluetooth import async_connect_scanner from .const import ( + CLIENT_INFO, CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, @@ -101,6 +106,26 @@ DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" UNPACK_UINT32_BE = struct.Struct(">I").unpack_from +@callback +def async_create_api_client( + hass: HomeAssistant, + entry: ESPHomeConfigEntry, + zeroconf_instance: zeroconf.HaZeroconf, + *, + noise_psk: str | None, +) -> APIClient: + """Create an APIClient for a config entry.""" + return APIClient( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_PASSWORD], + client_info=CLIENT_INFO, + zeroconf_instance=zeroconf_instance, + noise_psk=noise_psk, + timezone=hass.config.time_zone, + ) + + if TYPE_CHECKING: from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001 @@ -812,6 +837,51 @@ class ESPHomeManager: if self.reconnect_logic: await self.reconnect_logic.stop() + async def _async_provision_key_over_noise(self, new_key: bytes) -> bool: + """Send the encryption key over a short lived zero PSK Noise connection. + + The well known all zeros PSK still runs a fresh ephemeral X25519 + exchange, so the key cannot be read by a passive listener on the + network. This protects against sniffing only; it does not + authenticate either side against an active man in the middle. + + Returns True if the device accepted the key. On failure the caller + simply returns; provisioning runs again on the next connect cycle. + """ + unique_id = self.entry.unique_id + cli = async_create_api_client( + self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK + ) + device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host) + try: + await cli.connect() + if await cli.noise_encryption_set_key(new_key): + return True + _LOGGER.error( + "Device %s (%s) rejected the encryption key", + device_name, + unique_id, + ) + except InvalidEncryptionKeyAPIError: + _LOGGER.error( + "Device %s (%s) rejected the zero PSK handshake; it appears " + "to already have an encryption key set", + device_name, + unique_id, + ) + except APIConnectionError as ex: + # Whatever went wrong, we never downgrade to a plaintext push; + # provisioning simply runs again on the next connect cycle + _LOGGER.error( + "Error provisioning encryption key for device %s (%s): %s", + device_name, + unique_id, + ex, + ) + finally: + await cli.disconnect(force=True) + return False + async def _handle_dynamic_encryption_key( self, device_info: EsphomeDeviceInfo ) -> None: @@ -853,18 +923,24 @@ class ESPHomeManager: new_key = base64.b64encode(secrets.token_bytes(32)) new_key_str = new_key.decode() - try: - # Store the key on the device using the existing connection - result = await self.cli.noise_encryption_set_key(new_key) - except APIConnectionError as ex: - _LOGGER.error( - "Connection error while storing encryption key for device %s (%s): %s", - self.entry.data.get(CONF_DEVICE_NAME, self.host), - self.entry.unique_id, - ex, - ) - return + if device_info.api_encryption_provisionable: + # New firmware: send the key over an encrypted zero PSK Noise + # connection so it cannot be sniffed off the network + if not await self._async_provision_key_over_noise(new_key): + return else: + # Old firmware only accepts the key over the existing plaintext + # connection. Deprecated; will be removed after the usual window. + try: + result = await self.cli.noise_encryption_set_key(new_key) + except APIConnectionError as ex: + _LOGGER.error( + "Connection error while storing encryption key for device %s (%s): %s", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ex, + ) + return if not result: _LOGGER.error( "Failed to set dynamic encryption key on device %s (%s)", diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index bfb6aa97446f..8060f6aafe31 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -210,7 +210,7 @@ def mock_client(mock_device_info) -> Generator[APIClient]: "homeassistant.components.esphome.manager.ReconnectLogic", BaseMockReconnectLogic, ), - patch("homeassistant.components.esphome.APIClient", mock_client), + patch("homeassistant.components.esphome.manager.APIClient", mock_client), patch("homeassistant.components.esphome.config_flow.APIClient", mock_client), ): yield mock_client diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index dfde80addd55..70cdd63c5fa6 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,11 +2,13 @@ import asyncio import base64 +from collections.abc import Generator import logging from typing import Any from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -32,6 +34,7 @@ import pytest import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.esphome.config_flow import PROBE_NOISE_PSK from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, @@ -2724,6 +2727,208 @@ async def test_manager_handle_dynamic_encryption_key_connection_error( assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] +@pytest.fixture +def mock_provisioning_client(mock_client: APIClient) -> Generator[Mock]: + """Mock the APIClient built for the zero PSK provisioning connection.""" + client = Mock(spec=APIClient) + client.connect = AsyncMock() + client.disconnect = AsyncMock() + client.noise_encryption_set_key = AsyncMock(return_value=True) + + def _api_client(*args: Any, **kwargs: Any) -> Mock: + if kwargs.get("noise_psk") == ZERO_NOISE_PSK: + return client + return mock_client(*args, **kwargs) + + with patch( + "homeassistant.components.esphome.manager.APIClient", side_effect=_api_client + ): + yield client + + +def _make_provisionable_entry(hass: HomeAssistant, mac_address: str) -> MockConfigEntry: + """Create a config entry without a noise PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + return entry + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_provisioned_over_zero_psk( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test provisionable firmware gets the key over a zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + entry = _make_provisionable_entry(hass, mac_address) + + # The main (plaintext) client must never be used to push the key + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The key went over the zero PSK client (the fixture only hands it out + # for constructions using ZERO_NOISE_PSK), not the plaintext connection + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + base64.b64encode(test_key_bytes) + ) + mock_client.noise_encryption_set_key.assert_not_called() + mock_provisioning_client.disconnect.assert_called_with(force=True) + + # Entry and storage were updated + assert entry.data[CONF_NOISE_PSK] == expected_key + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +async def test_dynamic_encryption_key_provisioned_over_zero_psk_from_storage( + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test a stored key is re-provisioned over the zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + test_key.encode() + ) + mock_client.noise_encryption_set_key.assert_not_called() + assert entry.data[CONF_NOISE_PSK] == test_key + + +@pytest.mark.parametrize( + ("connect_error", "set_key_result"), + [ + # Device already has a key (distinct log branch) + (InvalidEncryptionKeyAPIError("already keyed"), True), + # Old firmware answering plaintext to the noise hello (generic branch; + # all connection errors are APIConnectionError subclasses) + (EncryptionPlaintextAPIError("plaintext"), True), + # Device accepted the connection but rejected the key + (None, False), + ], +) +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_zero_psk_failures_never_use_plaintext( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + connect_error: Exception | None, + set_key_result: bool, +) -> None: + """Test zero PSK provisioning failures do not fall back to plaintext.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # A None side_effect leaves connect behaving normally + mock_provisioning_client.connect.side_effect = connect_error + mock_provisioning_client.noise_encryption_set_key.return_value = set_key_result + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The plaintext connection was never used to push the key, the entry was + # not updated, and no generated key was stored + mock_client.noise_encryption_set_key.assert_not_called() + assert CONF_NOISE_PSK not in entry.data + assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] + mock_provisioning_client.disconnect.assert_called_with(force=True) + + +def test_zero_noise_psk_is_not_the_probe_key() -> None: + """Test the provisioning PSK is 32 zero bytes and differs from the probe.""" + assert base64.b64decode(ZERO_NOISE_PSK) == bytes(32) + assert ZERO_NOISE_PSK != PROBE_NOISE_PSK + + async def test_zwave_proxy_request_home_id_change( hass: HomeAssistant, mock_client: APIClient, From 0d4f51d207eed584229b7044d822bf742b77d91f Mon Sep 17 00:00:00 2001 From: bdlcalvin <149634165+bdlcalvin@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:26:50 -0400 Subject: [PATCH 637/707] Add oven cook mode select to Whirlpool (#173412) --- homeassistant/components/whirlpool/entity.py | 18 +- .../components/whirlpool/quality_scale.yaml | 4 +- homeassistant/components/whirlpool/select.py | 69 +++++- homeassistant/components/whirlpool/sensor.py | 48 ++-- .../components/whirlpool/strings.json | 49 ++++ homeassistant/components/whirlpool/util.py | 101 ++++++++ .../whirlpool/snapshots/test_select.ambr | 213 +++++++++++++++++ .../whirlpool/snapshots/test_sensor.ambr | 216 ------------------ tests/components/whirlpool/test_select.py | 126 ++++++++++ tests/components/whirlpool/test_sensor.py | 151 +++++++++--- 10 files changed, 718 insertions(+), 277 deletions(-) create mode 100644 homeassistant/components/whirlpool/util.py diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py index eb6a5759b7fc..ec98253e9716 100644 --- a/homeassistant/components/whirlpool/entity.py +++ b/homeassistant/components/whirlpool/entity.py @@ -75,6 +75,15 @@ class WhirlpoolOvenEntity(WhirlpoolEntity): _appliance: Oven + @staticmethod + def cavity_suffix(oven: Oven, cavity: OvenCavity) -> str: + """Return the unique-id and translation-key suffix for an oven cavity.""" + if oven.get_oven_cavity_exists( + OvenCavity.Upper + ) and oven.get_oven_cavity_exists(OvenCavity.Lower): + return "_upper" if cavity == OvenCavity.Upper else "_lower" + return "" + def __init__( self, appliance: Oven, @@ -84,14 +93,7 @@ class WhirlpoolOvenEntity(WhirlpoolEntity): ) -> None: """Initialize the entity.""" self.cavity = cavity - cavity_suffix = "" - if appliance.get_oven_cavity_exists( - OvenCavity.Upper - ) and appliance.get_oven_cavity_exists(OvenCavity.Lower): - if cavity == OvenCavity.Upper: - cavity_suffix = "_upper" - elif cavity == OvenCavity.Lower: - cavity_suffix = "_lower" + cavity_suffix = self.cavity_suffix(appliance, cavity) super().__init__( appliance, unique_id_suffix=f"{unique_id_suffix}{cavity_suffix}" ) diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml index 2f75dd42e1aa..1a444ee0f4e5 100644 --- a/homeassistant/components/whirlpool/quality_scale.yaml +++ b/homeassistant/components/whirlpool/quality_scale.yaml @@ -74,9 +74,7 @@ rules: comment: | Time remaining sensor still has hardcoded icon. reconfiguration-flow: todo - repair-issues: - status: exempt - comment: No known use cases for repair issues or flows, yet + repair-issues: done stale-devices: todo # Platinum diff --git a/homeassistant/components/whirlpool/select.py b/homeassistant/components/whirlpool/select.py index 9bac108976a8..6df32716ab69 100644 --- a/homeassistant/components/whirlpool/select.py +++ b/homeassistant/components/whirlpool/select.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Final, override from whirlpool.appliance import Appliance +from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import UnitOfTemperature @@ -14,10 +15,26 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry from .const import DOMAIN -from .entity import WhirlpoolEntity +from .entity import WhirlpoolEntity, WhirlpoolOvenEntity PARALLEL_UPDATES = 1 +OVEN_COOK_MODES: Final[dict[CookMode, str]] = { + CookMode.Standby: "standby", + CookMode.Bake: "bake", + CookMode.ConvectBake: "convection_bake", + CookMode.Broil: "broil", + CookMode.ConvectBroil: "convection_broil", + CookMode.ConvectRoast: "convection_roast", + CookMode.KeepWarm: "keep_warm", + CookMode.AirFry: "air_fry", +} +OPTION_TO_OVEN_COOK_MODE: Final = {v: k for k, v in OVEN_COOK_MODES.items()} + +# Target temperature (Celsius) used when a mode is selected while the oven is +# idle and has no target set yet. +DEFAULT_OVEN_TEMP = 175 + @dataclass(frozen=True, kw_only=True) class WhirlpoolSelectDescription(SelectEntityDescription): @@ -49,11 +66,18 @@ async def async_setup_entry( """Set up the select platform.""" appliances_manager = config_entry.runtime_data - async_add_entities( + entities: list[SelectEntity] = [ WhirlpoolSelectEntity(refrigerator, description) for refrigerator in appliances_manager.refrigerators for description in REFRIGERATOR_DESCRIPTIONS + ] + entities.extend( + WhirlpoolOvenCookModeSelect(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) ) + async_add_entities(entities) class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): @@ -84,3 +108,44 @@ class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): translation_domain=DOMAIN, translation_key="invalid_value_set", ) from err + + +class WhirlpoolOvenCookModeSelect(WhirlpoolOvenEntity, SelectEntity): + """Settable cook mode for an oven cavity.""" + + _attr_options = list(OVEN_COOK_MODES.values()) + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven cook mode select.""" + super().__init__(appliance, cavity, "oven_cook_mode", "-cook_mode") + + @override + @property + def current_option(self) -> str | None: + """Return the current cook mode, if it is a selectable one.""" + return OVEN_COOK_MODES.get(self._appliance.get_cook_mode(self.cavity)) + + @override + async def async_select_option(self, option: str) -> None: + """Set the cook mode, keeping the current/last target temperature.""" + mode = OPTION_TO_OVEN_COOK_MODE[option] + try: + if mode == CookMode.Standby: + # Standby is the idle state: the oven reaches it by cancelling + # the current cook, not by starting a "standby" cook. + result = await self._appliance.stop_cook(self.cavity) + else: + target = self._appliance.get_target_temp(self.cavity) + if target is None: + target = DEFAULT_OVEN_TEMP + result = await self._appliance.set_cook( + target_temp=target, + mode=mode, + cavity=self.cavity, + ) + WhirlpoolOvenCookModeSelect._check_service_request(result) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 9bc5b73e7e9c..c2b31c641486 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -23,14 +23,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import WhirlpoolConfigEntry from .entity import WhirlpoolEntity, WhirlpoolOvenEntity +from .util import deprecate_entity PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(minutes=5) @@ -257,6 +259,30 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = ( ) +def _build_oven_cavity_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + oven: Oven, + cavity: OvenCavity, +) -> list[SensorEntity]: + """Build the sensors for a single oven cavity, handling deprecations.""" + suffix = WhirlpoolOvenEntity.cavity_suffix(oven, cavity) + sensors: list[SensorEntity] = [] + for description in OVEN_CAVITY_SENSORS: + # The oven cook mode sensor has been replaced by a select entity. + if description.key == "oven_cook_mode" and not deprecate_entity( + hass, + entity_registry, + platform_domain=Platform.SENSOR, + entity_unique_id=f"{oven.said}-oven_cook_mode{suffix}", + issue_id=f"deprecated_oven_cook_mode_{oven.said}{suffix}", + translation_key="deprecated_oven_cook_mode", + ): + continue + sensors.append(WhirlpoolOvenCavitySensor(oven, cavity, description)) + return sensors + + async def async_setup_entry( hass: HomeAssistant, config_entry: WhirlpoolConfigEntry, @@ -289,18 +315,13 @@ async def async_setup_entry( for description in WASHER_DRYER_TIME_SENSORS ] - oven_upper_cavity_sensors = [ - WhirlpoolOvenCavitySensor(oven, OvenCavity.Upper, description) + entity_registry = er.async_get(hass) + oven_cavity_sensors = [ + sensor for oven in appliances_manager.ovens - if oven.get_oven_cavity_exists(OvenCavity.Upper) - for description in OVEN_CAVITY_SENSORS - ] - - oven_lower_cavity_sensors = [ - WhirlpoolOvenCavitySensor(oven, OvenCavity.Lower, description) - for oven in appliances_manager.ovens - if oven.get_oven_cavity_exists(OvenCavity.Lower) - for description in OVEN_CAVITY_SENSORS + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + for sensor in _build_oven_cavity_sensors(hass, entity_registry, oven, cavity) ] async_add_entities( @@ -309,8 +330,7 @@ async def async_setup_entry( *washer_time_sensors, *dryer_sensors, *dryer_time_sensors, - *oven_upper_cavity_sensors, - *oven_lower_cavity_sensors, + *oven_cavity_sensors, ] ) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 5c5581afa5fa..d08591304340 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -69,6 +69,45 @@ } }, "select": { + "oven_cook_mode": { + "name": "Cook mode", + "state": { + "air_fry": "Air fry", + "bake": "Bake", + "broil": "Broil", + "convection_bake": "Convection bake", + "convection_broil": "Convection broil", + "convection_roast": "Convection roast", + "keep_warm": "Keep warm", + "standby": "[%key:common::state::standby%]" + } + }, + "oven_cook_mode_lower": { + "name": "Lower oven cook mode", + "state": { + "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]", + "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]", + "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]", + "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]", + "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]", + "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]", + "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]", + "standby": "[%key:common::state::standby%]" + } + }, + "oven_cook_mode_upper": { + "name": "Upper oven cook mode", + "state": { + "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]", + "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]", + "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]", + "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]", + "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]", + "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]", + "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]", + "standby": "[%key:common::state::standby%]" + } + }, "refrigerator_temperature_level": { "name": "Temperature level" } @@ -250,5 +289,15 @@ "request_failed": { "message": "Request failed" } + }, + "issues": { + "deprecated_oven_cook_mode": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nUpdate any dashboards, templates, automations or scripts to use the new select entity, then disable `{entity_id}` to have it removed.", + "title": "The Whirlpool oven cook mode sensor is deprecated" + }, + "deprecated_oven_cook_mode_scripts": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new select entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::whirlpool::issues::deprecated_oven_cook_mode::title%]" + } } } diff --git a/homeassistant/components/whirlpool/util.py b/homeassistant/components/whirlpool/util.py new file mode 100644 index 000000000000..a2dc9c6a7ebc --- /dev/null +++ b/homeassistant/components/whirlpool/util.py @@ -0,0 +1,101 @@ +"""Utility helpers for the Whirlpool integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + +# Version in which deprecated entities will be removed. +DEPRECATED_REMOVAL_VERSION = "2026.12.0" + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + *, + platform_domain: str, + entity_unique_id: str, + issue_id: str, + translation_key: str, +) -> bool: + """Handle deprecation of an entity that has been replaced. + + Return True if the deprecated entity should still be set up, which is the + case while it exists in the entity registry. A repair issue informs the user + about the replacement and the removal date; when the entity is still used by + automations or scripts they are listed in the issue. The entity is removed + once the user disables it and nothing references it anymore. New + installations never create the entity. + """ + entity_id = entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ) + if entity_id is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + items = _automations_and_scripts_using_entity(hass, entity_registry, entity_id) + + if entity_entry.disabled and not items: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, issue_id) + return False + + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or entity_id, + } + if items: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join(items) + + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version=DEPRECATED_REMOVAL_VERSION, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + + +def _automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_id: str, +) -> list[str]: + """Return markdown list items for automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + items: list[str] = [] + for integration, used_entities in ( + ("automation", automations), + ("script", scripts), + ): + for used_entity_id in used_entities: + if entry := entity_registry.async_get(used_entity_id): + items.append( + f"- [{entry.original_name}](/config/{integration}/edit/{entry.unique_id})" + ) + else: + items.append(f"- `{used_entity_id}`") + + return items diff --git a/tests/components/whirlpool/snapshots/test_select.ambr b/tests/components/whirlpool/snapshots/test_select.ambr index 3371180eadcb..191c9a371d30 100644 --- a/tests/components/whirlpool/snapshots/test_select.ambr +++ b/tests/components/whirlpool/snapshots/test_select.ambr @@ -65,3 +65,216 @@ 'state': '0', }) # --- +# name: test_all_entities[select.dual_cavity_oven_lower_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dual_cavity_oven_lower_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower oven cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode_lower', + 'unique_id': 'said_oven_dual-cook_mode_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_lower_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Dual cavity oven Lower oven cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.dual_cavity_oven_lower_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_upper_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dual_cavity_oven_upper_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper oven cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode_upper', + 'unique_id': 'said_oven_dual-cook_mode_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_upper_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Dual cavity oven Upper oven cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.dual_cavity_oven_upper_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[select.single_cavity_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.single_cavity_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode', + 'unique_id': 'said_oven_single-cook_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.single_cavity_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Single cavity oven Cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.single_cavity_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index f4ec93c81fe0..d86b359ed3c8 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -149,78 +149,6 @@ 'state': 'running_maincycle', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Lower oven cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lower oven cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode_lower', - 'unique_id': 'said_oven_dual-oven_cook_mode_lower', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Dual cavity oven Lower oven cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_lower_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -399,78 +327,6 @@ 'state': '200', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Upper oven cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Upper oven cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode_upper', - 'unique_id': 'said_oven_dual-oven_cook_mode_upper', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Dual cavity oven Upper oven cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_upper_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -649,78 +505,6 @@ 'state': '200', }) # --- -# name: test_all_entities[sensor.single_cavity_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.single_cavity_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode', - 'unique_id': 'said_oven_single-oven_cook_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Single cavity oven Cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.single_cavity_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.single_cavity_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/whirlpool/test_select.py b/tests/components/whirlpool/test_select.py index 665b0cb44bf0..fc56c59792c8 100644 --- a/tests/components/whirlpool/test_select.py +++ b/tests/components/whirlpool/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion +import whirlpool from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform @@ -94,3 +95,128 @@ async def test_select_option_value_error( }, blocking=True, ) + + +@pytest.fixture( + params=[ + ( + "select.single_cavity_oven_cook_mode", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "select.dual_cavity_oven_upper_oven_cook_mode", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "select.dual_cavity_oven_lower_oven_cook_mode", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_cook_mode_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven cook-mode select entities.""" + return request.param + + +async def test_oven_cook_mode_current( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test reading the current cook mode.""" + entity_id, mock_fixture, _ = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + assert hass.states.get(entity_id).state == "bake" + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await trigger_attr_callback(hass, mock) + assert hass.states.get(entity_id).state == "broil" + + +async def test_oven_cook_mode_select( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting a cook mode issues a cook command.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=200, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_oven_cook_mode_select_from_idle( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting a mode with no target set uses the default temperature.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_target_temp.return_value = None + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=175, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_oven_cook_mode_select_standby( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting standby stops the cook instead of starting one.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "standby"}, + blocking=True, + ) + mock.stop_cook.assert_called_once_with(cavity) + mock.set_cook.assert_not_called() + + +async def test_oven_cook_mode_select_value_error( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a ValueError while setting the cook mode raises ServiceValidationError.""" + entity_id, mock_fixture, _ = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.side_effect = ValueError + await init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 578232f4641b..cda4509462b8 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -6,13 +6,16 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from whirlpool.dryer import MachineState as DryerMachineState -from whirlpool.oven import CavityState as OvenCavityState, CookMode +from whirlpool.oven import CavityState as OvenCavityState from whirlpool.washer import MachineState as WasherMachineState +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.whirlpool.const import DOMAIN from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback @@ -324,22 +327,6 @@ async def test_washer_running_states( (None, STATE_UNKNOWN), ], ), - ( - "sensor.dual_cavity_oven_upper_oven_cook_mode", - "mock_oven_dual_cavity_api", - "get_cook_mode", - [ - (CookMode.Standby, "standby"), - (CookMode.Bake, "bake"), - (CookMode.ConvectBake, "convection_bake"), - (CookMode.Broil, "broil"), - (CookMode.ConvectBroil, "convection_broil"), - (CookMode.ConvectRoast, "convection_roast"), - (CookMode.KeepWarm, "keep_warm"), - (CookMode.AirFry, "air_fry"), - (None, STATE_UNKNOWN), - ], - ), ( "sensor.single_cavity_oven_state", "mock_oven_single_cavity_api", @@ -351,22 +338,6 @@ async def test_washer_running_states( (None, STATE_UNKNOWN), ], ), - ( - "sensor.single_cavity_oven_cook_mode", - "mock_oven_single_cavity_api", - "get_cook_mode", - [ - (CookMode.Standby, "standby"), - (CookMode.Bake, "bake"), - (CookMode.ConvectBake, "convection_bake"), - (CookMode.Broil, "broil"), - (CookMode.ConvectBroil, "convection_broil"), - (CookMode.ConvectRoast, "convection_roast"), - (CookMode.KeepWarm, "keep_warm"), - (CookMode.AirFry, "air_fry"), - (None, STATE_UNKNOWN), - ], - ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -390,3 +361,115 @@ async def test_simple_enum_sensors( state = hass.states.get(entity_id) assert state is not None assert state.state == expected_state + + +# The oven cook mode sensor has been replaced by a select entity and is deprecated. +DEPRECATED_COOK_MODE_UNIQUE_ID = "said_oven_single-oven_cook_mode" +DEPRECATED_COOK_MODE_ISSUE_ID = "deprecated_oven_cook_mode_said_oven_single" + + +async def test_oven_cook_mode_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated cook mode sensor is not created on a fresh install.""" + await init_integration(hass) + + assert hass.states.get("sensor.single_cavity_oven_cook_mode") is None + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_cook_mode_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an existing cook mode sensor is kept and raises a repair issue.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + ) + + await init_integration(hass) + + state = hass.states.get("sensor.single_cavity_oven_cook_mode") + assert state is not None + assert state.state == "bake" + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) in issue_registry.issues + + +async def test_oven_cook_mode_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled deprecated cook mode sensor is removed and the issue cleared.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await init_integration(hass) + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_cook_mode_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled cook mode sensor used by an automation is kept and flagged.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "trigger": { + "platform": "state", + "entity_id": "sensor.single_cavity_oven_cook_mode", + }, + "action": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await init_integration(hass) + + # The sensor is still referenced by an automation, so it is kept and the + # repair issue switches to the variant that lists the usage. + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) + assert issue is not None + assert issue.translation_key == "deprecated_oven_cook_mode_scripts" From c032767fdc6810bdb4305bdf199709c3d74db514 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Thu, 16 Jul 2026 03:32:12 +0530 Subject: [PATCH 638/707] Fix infrared NEC test command to a valid 8-bit value (#176573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa Co-authored-by: Manu --- tests/components/infrared/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index 902f1035877d..6f3823d71538 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -38,7 +38,7 @@ from tests.common import ( TEST_DOMAIN = "test" -TEST_COMMAND = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) +TEST_COMMAND = NECCommand(address=0x04FB, command=0xF7, modulation=38000) async def test_get_entities_component_not_loaded(hass: HomeAssistant) -> None: From 26ea75467968756660d3d182ddae7655451711ef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:25:06 +0100 Subject: [PATCH 639/707] Update infrared-protocols to 7.5.0 (#176580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 7f699ac774d1..8958e81d9eba 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==7.0.0"] + "requirements": ["infrared-protocols==7.5.0"] } diff --git a/requirements.txt b/requirements.txt index cb713db0f214..79d031f9c272 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==7.0.0 +infrared-protocols==7.5.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index bc3b99511ff9..158ecc2b6d8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1371,7 +1371,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==7.0.0 +infrared-protocols==7.5.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 From dba2a2b0f8bcffce221422bbdb2aa14fdcf9aedb Mon Sep 17 00:00:00 2001 From: Samuel Cabrero Date: Thu, 16 Jul 2026 00:28:17 +0200 Subject: [PATCH 640/707] Bump pytrydan to 1.0.4 (#176575) Signed-off-by: Samuel Cabrero --- homeassistant/components/v2c/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/v2c/snapshots/test_diagnostics.ambr | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ddad80b92f24..903280052d00 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/v2c", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pytrydan==1.0.3"] + "requirements": ["pytrydan==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 158ecc2b6d8c..254375368eb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2813,7 +2813,7 @@ pytradfri[async]==9.0.1 pytrafikverket==1.1.1 # homeassistant.components.v2c -pytrydan==1.0.3 +pytrydan==1.0.4 # homeassistant.components.uptimerobot pyuptimerobot==25.0.0 diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 847366bf21a3..f70c73280ee9 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -22,7 +22,7 @@ 'unique_id': 'ABC123', 'version': 1, }), - 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", 'host_status': 200, 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"VoltageInstallation":230,"ChargeEnergy":1.8,"ChargeMode":1,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', }) From f3c69980ed65d191414e386aaca03ff89a1441a9 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:40:09 +0200 Subject: [PATCH 641/707] Bump uiprotect to 15.14.2 (#176578) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 66513f125d71..5d2673998cef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.12.2"] + "requirements": ["uiprotect==15.14.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 254375368eb9..603043347a0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3261,7 +3261,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.12.2 +uiprotect==15.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 From 2b30582a1816b7f1d793400ac1ed5bbf12606645 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 16 Jul 2026 00:12:08 -0700 Subject: [PATCH 642/707] Add Google Health calories, floors, and body fat sensors (#176533) --- .../components/google_health/coordinator.py | 63 +++-- .../components/google_health/sensor.py | 37 ++- .../components/google_health/strings.json | 12 + tests/components/google_health/conftest.py | 20 ++ .../fixtures/active_energy_burned.json | 23 ++ .../google_health/fixtures/body_fat.json | 12 + .../google_health/fixtures/floors.json | 23 ++ .../fixtures/total_calories.json | 23 ++ .../google_health/snapshots/test_sensor.ambr | 215 ++++++++++++++++++ tests/components/google_health/test_init.py | 12 + tests/components/google_health/test_sensor.py | 17 +- 11 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 tests/components/google_health/fixtures/active_energy_burned.json create mode 100644 tests/components/google_health/fixtures/body_fat.json create mode 100644 tests/components/google_health/fixtures/floors.json create mode 100644 tests/components/google_health/fixtures/total_calories.json diff --git a/homeassistant/components/google_health/coordinator.py b/homeassistant/components/google_health/coordinator.py index 680bccb0e1b6..a9b515b998aa 100644 --- a/homeassistant/components/google_health/coordinator.py +++ b/homeassistant/components/google_health/coordinator.py @@ -1,5 +1,6 @@ """Coordinators for Google Health.""" +import asyncio from dataclasses import dataclass from datetime import timedelta import logging @@ -12,9 +13,13 @@ from google_health_api.exceptions import ( HealthAuthException, ) from google_health_api.model import ( + ActiveEnergyBurnedRollupValue, + BodyFat, DailyRestingHeartRate, DistanceRollupValue, + FloorsRollupValue, StepsRollupValue, + TotalCaloriesRollupValue, Weight, ) @@ -40,6 +45,9 @@ class GoogleHealthActivityData: steps: StepsRollupValue | None = None distance: DistanceRollupValue | None = None + active_energy_burned: ActiveEnergyBurnedRollupValue | None = None + total_calories: TotalCaloriesRollupValue | None = None + floors: FloorsRollupValue | None = None @dataclass @@ -48,6 +56,7 @@ class GoogleHealthBodyData: weight: Weight | None = None resting_heart_rate: DailyRestingHeartRate | None = None + body_fat: BodyFat | None = None class GoogleHealthDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -116,20 +125,42 @@ class GoogleHealthActivityCoordinator( @override async def _async_fetch_data(self) -> GoogleHealthActivityData: - """Fetch steps and distance rollup for today. + """Fetch activity rollups for today. - Queries the daily rollup endpoints using Home Assistant's local time zone - to aggregate step and distance counts over the current civil day. If no - data points exist for today yet, the API returns None, which the sensors - default to 0. + Queries the daily rollup endpoints in parallel using Home Assistant's + local time zone to aggregate steps, distance, active calories, total + calories, and floors. If no data points exist for today yet, the API + returns None, which the sensors default to 0. """ - steps_rollup = await self.api.steps.today(self.hass.config.time_zone) - distance_rollup = await self.api.distance.today(self.hass.config.time_zone) + ( + steps_rollup, + distance_rollup, + active_energy_rollup, + total_calories_rollup, + floors_rollup, + ) = await asyncio.gather( + self.api.steps.today(self.hass.config.time_zone), + self.api.distance.today(self.hass.config.time_zone), + self.api.active_energy_burned.today(self.hass.config.time_zone), + self.api.total_calories.today(self.hass.config.time_zone), + self.api.floors.today(self.hass.config.time_zone), + ) steps = steps_rollup.data if steps_rollup else None distance = distance_rollup.data if distance_rollup else None + active_energy_burned = ( + active_energy_rollup.data if active_energy_rollup else None + ) + total_calories = total_calories_rollup.data if total_calories_rollup else None + floors = floors_rollup.data if floors_rollup else None - return GoogleHealthActivityData(steps=steps, distance=distance) + return GoogleHealthActivityData( + steps=steps, + distance=distance, + active_energy_burned=active_energy_burned, + total_calories=total_calories, + floors=floors, + ) class GoogleHealthBodyCoordinator( @@ -155,13 +186,14 @@ class GoogleHealthBodyCoordinator( @override async def _async_fetch_data(self) -> GoogleHealthBodyData: - """Fetch latest body weight and resting heart rate.""" + """Fetch latest body weight, resting heart rate, and body fat in parallel.""" # The Google Health API returns data points sorted by interval start time # in descending order (newest first). Querying with page_size=1 and grabbing # the first element is sufficient to fetch the most recent measurement. - weight_result = await self.api.weight.list(page_size=DEFAULT_PAGE_SIZE) - hr_result = await self.api.daily_resting_heart_rate.list( - page_size=DEFAULT_PAGE_SIZE + weight_result, hr_result, body_fat_result = await asyncio.gather( + self.api.weight.list(page_size=DEFAULT_PAGE_SIZE), + self.api.daily_resting_heart_rate.list(page_size=DEFAULT_PAGE_SIZE), + self.api.body_fat.list(page_size=DEFAULT_PAGE_SIZE), ) weight = ( @@ -170,7 +202,12 @@ class GoogleHealthBodyCoordinator( resting_heart_rate = ( hr_result.data_points[0].data if hr_result.data_points else None ) + body_fat = ( + body_fat_result.data_points[0].data if body_fat_result.data_points else None + ) return GoogleHealthBodyData( - weight=weight, resting_heart_rate=resting_heart_rate + weight=weight, + resting_heart_rate=resting_heart_rate, + body_fat=body_fat, ) diff --git a/homeassistant/components/google_health/sensor.py b/homeassistant/components/google_health/sensor.py index f841058115d4..004f84bce3de 100644 --- a/homeassistant/components/google_health/sensor.py +++ b/homeassistant/components/google_health/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfLength, UnitOfMass +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -56,6 +56,32 @@ ACTIVITY_SENSORS: list[ data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0 ), ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="active_calories", + translation_key="active_calories", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.active_energy_burned.kcal_sum + if data and data.active_energy_burned + else 0.0 + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="total_calories", + translation_key="total_calories", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.total_calories.kcal_sum if data and data.total_calories else 0.0 + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int]( + key="floors", + translation_key="floors", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.floors.count_sum if data and data.floors else 0, + ), ] BODY_SENSORS: list[ @@ -81,6 +107,15 @@ BODY_SENSORS: list[ else None ), ), + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None]( + key="body_fat", + translation_key="body_fat", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.body_fat.percentage if data and data.body_fat else None + ), + ), ] diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json index 8159cafe0444..3263e03978f1 100644 --- a/homeassistant/components/google_health/strings.json +++ b/homeassistant/components/google_health/strings.json @@ -35,12 +35,24 @@ }, "entity": { "sensor": { + "active_calories": { + "name": "Active calories" + }, + "body_fat": { + "name": "Body fat" + }, + "floors": { + "name": "Floors" + }, "resting_heart_rate": { "name": "Resting heart rate" }, "steps": { "name": "Steps", "unit_of_measurement": "steps" + }, + "total_calories": { + "name": "Total calories" } } }, diff --git a/tests/components/google_health/conftest.py b/tests/components/google_health/conftest.py index 275936e3929e..783ad31df148 100644 --- a/tests/components/google_health/conftest.py +++ b/tests/components/google_health/conftest.py @@ -6,15 +6,19 @@ from typing import Any from unittest.mock import AsyncMock, patch from google_health_api.model import ( + BODY_FAT, DAILY_RESTING_HEART_RATE, WEIGHT, + ActiveEnergyBurnedRollupValue, DailyRollupDataPoint, DataPoint, DataType, DistanceRollupValue, + FloorsRollupValue, Identity, ListDataPointResult, StepsRollupValue, + TotalCaloriesRollupValue, UserInfo, _ListDataPointsModel, ) @@ -129,6 +133,20 @@ def mock_google_health_client() -> Generator[AsyncMock]: client.distance.today.return_value = _rollup_fixture( "distance.json", DistanceRollupValue, "distance" ) + client.active_energy_burned = AsyncMock() + client.active_energy_burned.today.return_value = _rollup_fixture( + "active_energy_burned.json", + ActiveEnergyBurnedRollupValue, + "activeEnergyBurned", + ) + client.total_calories = AsyncMock() + client.total_calories.today.return_value = _rollup_fixture( + "total_calories.json", TotalCaloriesRollupValue, "totalCalories" + ) + client.floors = AsyncMock() + client.floors.today.return_value = _rollup_fixture( + "floors.json", FloorsRollupValue, "floors" + ) client.weight = AsyncMock() client.weight.list.return_value = _list_fixture("weight.json", WEIGHT) client.weight.required_read_scopes = [ @@ -138,6 +156,8 @@ def mock_google_health_client() -> Generator[AsyncMock]: client.daily_resting_heart_rate.list.return_value = _list_fixture( "resting_heart_rate.json", DAILY_RESTING_HEART_RATE ) + client.body_fat = AsyncMock() + client.body_fat.list.return_value = _list_fixture("body_fat.json", BODY_FAT) client.get_identity.return_value = Identity.from_dict( load_json_object_fixture("identity.json", DOMAIN) ) diff --git a/tests/components/google_health/fixtures/active_energy_burned.json b/tests/components/google_health/fixtures/active_energy_burned.json new file mode 100644 index 000000000000..f8365250bfe5 --- /dev/null +++ b/tests/components/google_health/fixtures/active_energy_burned.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "activeEnergyBurned": { + "kcalSum": 350.5 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/body_fat.json b/tests/components/google_health/fixtures/body_fat.json new file mode 100644 index 000000000000..76b35c2e197d --- /dev/null +++ b/tests/components/google_health/fixtures/body_fat.json @@ -0,0 +1,12 @@ +{ + "dataPoints": [ + { + "bodyFat": { + "percentage": 18.5, + "sampleTime": { + "physicalTime": "2026-06-29T00:00:00Z" + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/floors.json b/tests/components/google_health/fixtures/floors.json new file mode 100644 index 000000000000..c507b4c97b10 --- /dev/null +++ b/tests/components/google_health/fixtures/floors.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "floors": { + "countSum": 5 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/total_calories.json b/tests/components/google_health/fixtures/total_calories.json new file mode 100644 index 000000000000..f78675d39388 --- /dev/null +++ b/tests/components/google_health/fixtures/total_calories.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "totalCalories": { + "kcalSum": 2100.2 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/snapshots/test_sensor.ambr b/tests/components/google_health/snapshots/test_sensor.ambr index b1973b10e0ae..6dbd4ffb0cdb 100644 --- a/tests/components/google_health/snapshots/test_sensor.ambr +++ b/tests/components/google_health/snapshots/test_sensor.ambr @@ -1,4 +1,112 @@ # serializer version: 1 +# name: test_all_entities[sensor.google_health_active_calories-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_active_calories', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active calories', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active calories', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_calories', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_active_calories', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_active_calories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Active calories', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_active_calories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '350.5', + }) +# --- +# name: test_all_entities[sensor.google_health_body_fat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_body_fat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Body fat', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Body fat', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'body_fat', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_body_fat', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.google_health_body_fat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Body fat', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.google_health_body_fat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- # name: test_all_entities[sensor.google_health_distance-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -57,6 +165,59 @@ 'state': '5000.0', }) # --- +# name: test_all_entities[sensor.google_health_floors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_floors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floors', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floors', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_floors', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.google_health_floors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Floors', + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_floors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_all_entities[sensor.google_health_resting_heart_rate-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -165,6 +326,60 @@ 'state': '10500', }) # --- +# name: test_all_entities[sensor.google_health_total_calories-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_total_calories', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total calories', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total calories', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_calories', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_total_calories', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_total_calories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Total calories', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_total_calories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2100.2', + }) +# --- # name: test_all_entities[sensor.google_health_weight-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/google_health/test_init.py b/tests/components/google_health/test_init.py index ec66763deebd..766d26d1c209 100644 --- a/tests/components/google_health/test_init.py +++ b/tests/components/google_health/test_init.py @@ -1,5 +1,6 @@ """Tests for Google Health integration lifecycle (init/unloading).""" +import asyncio from collections.abc import Awaitable, Callable from datetime import timedelta from unittest.mock import AsyncMock, patch @@ -111,9 +112,13 @@ async def test_setup_missing_activity_scope( assert hass.states.get("sensor.google_health_steps") is None assert hass.states.get("sensor.google_health_distance") is None + assert hass.states.get("sensor.google_health_active_calories") is None + assert hass.states.get("sensor.google_health_total_calories") is None + assert hass.states.get("sensor.google_health_floors") is None assert hass.states.get("sensor.google_health_weight") is not None assert hass.states.get("sensor.google_health_resting_heart_rate") is not None + assert hass.states.get("sensor.google_health_body_fat") is not None @pytest.mark.usefixtures("mock_google_health_client") @@ -137,9 +142,13 @@ async def test_setup_missing_measurements_scope( assert hass.states.get("sensor.google_health_weight") is None assert hass.states.get("sensor.google_health_resting_heart_rate") is None + assert hass.states.get("sensor.google_health_body_fat") is None assert hass.states.get("sensor.google_health_steps") is not None assert hass.states.get("sensor.google_health_distance") is not None + assert hass.states.get("sensor.google_health_active_calories") is not None + assert hass.states.get("sensor.google_health_total_calories") is not None + assert hass.states.get("sensor.google_health_floors") is not None async def test_setup_oauth_implementation_unavailable( @@ -182,6 +191,9 @@ async def test_runtime_auth_error( dt_util.utcnow() + POLLING_INTERVAL + timedelta(seconds=1), ) await hass.async_block_till_done() + # Yield to let untracked asyncio.gather tasks run + await asyncio.sleep(0) + await hass.async_block_till_done() # Verify that the flow was initiated flows = hass.config_entries.flow.async_progress() diff --git a/tests/components/google_health/test_sensor.py b/tests/components/google_health/test_sensor.py index 9e3b9b0f9600..f14a8017c907 100644 --- a/tests/components/google_health/test_sensor.py +++ b/tests/components/google_health/test_sensor.py @@ -33,9 +33,12 @@ async def test_sensor_empty_rollup( mock_google_health_client: AsyncMock, integration_setup: Callable[[], Awaitable[bool]], ) -> None: - """Test steps and distance sensors when the rollup endpoint returns no data.""" + """Test rollup sensors when the rollup endpoints return no data.""" mock_google_health_client.steps.today.return_value = None mock_google_health_client.distance.today.return_value = None + mock_google_health_client.active_energy_burned.today.return_value = None + mock_google_health_client.total_calories.today.return_value = None + mock_google_health_client.floors.today.return_value = None assert await integration_setup() @@ -46,3 +49,15 @@ async def test_sensor_empty_rollup( distance_state = hass.states.get("sensor.google_health_distance") assert distance_state is not None assert distance_state.state == "0.0" + + active_calories_state = hass.states.get("sensor.google_health_active_calories") + assert active_calories_state is not None + assert active_calories_state.state == "0.0" + + total_calories_state = hass.states.get("sensor.google_health_total_calories") + assert total_calories_state is not None + assert total_calories_state.state == "0.0" + + floors_state = hass.states.get("sensor.google_health_floors") + assert floors_state is not None + assert floors_state.state == "0" From a69a6893c0e95d6a49c252dc365c03d1a338db5a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 16 Jul 2026 10:09:37 +0200 Subject: [PATCH 643/707] Revert unusable pending HTTP config in place during setup (#176384) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Robert Resch --- homeassistant/components/http/__init__.py | 261 ++++++-- homeassistant/components/http/config.py | 12 + homeassistant/components/http/web_runner.py | 65 -- tests/components/http/test_init.py | 664 +++++++++++++++----- tests/conftest.py | 9 +- 5 files changed, 734 insertions(+), 277 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 4474afcb0cd5..04b622df32bb 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -59,7 +59,14 @@ from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans -from .config import async_load_config, default_server_port +from .config import ( + _DEFAULT_CONFIG, + ConfData, + HTTPConfigStore, + async_get_and_load_store, + async_load_config, + default_server_port, +) from .const import ( # noqa: F401 CONF_BASE_URL, CONF_CORS_ORIGINS, @@ -89,7 +96,7 @@ from .headers import setup_headers from .request_context import setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource -from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite +from .web_runner import HomeAssistantUnixSite _LOGGER: Final = logging.getLogger(__name__) @@ -167,6 +174,63 @@ class ApiConfig: self.use_ssl = use_ssl +async def _async_fallback_config( + hass: HomeAssistant, + store: HTTPConfigStore, + conf: ConfData, + err: HomeAssistantError | OSError, +) -> ConfData: + """Return the next config to try after ``conf`` could not be applied. + + Implements the fallback chain pending -> stable -> default config, where + the last step is only taken in recovery mode. Raises when there is no + (acceptable) fallback left, failing setup: on a normal boot this + activates recovery mode, in recovery mode it makes the failure visible + to the outside (e.g. the Supervisor rolls back a Core update whose API + does not come up). + """ + if store.revert_deadline is not None: + # An unconfirmed pending config is under trial and cannot even be + # applied, so it is known to be bad: revert to the stable config + # right away and continue this same start with it, instead of + # waiting out the trial window and restarting. + _LOGGER.error( + "The new HTTP configuration could not be applied, reverting to " + "the previous configuration: %s", + err, + ) + await store.async_abort_trial() + return store.stable + + if ( + # In normal mode, fail setup so recovery mode can take over with a + # reachable configuration. + not hass.config.recovery_mode + # The chain is exhausted; nothing left to fall back to. + or conf is _DEFAULT_CONFIG + # With peer certificate verification configured, connections must + # never be accepted without a verified client certificate; there is + # no acceptable fallback config. + or CONF_SSL_PEER_CERTIFICATE in conf + ): + # An unusable SSL configuration already carries a descriptive + # HomeAssistantError. + if isinstance(err, HomeAssistantError): + raise err + raise HomeAssistantError( + f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" + ) from err + + # The config cannot be applied in recovery mode; fall back to the + # default config so the recovery UI stays reachable. + _LOGGER.error( + "The HTTP configuration could not be applied in recovery mode, " + "falling back to the default configuration: %s", + err, + ) + return _DEFAULT_CONFIG + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" # Late import to ensure isal is updated before @@ -187,6 +251,67 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api_module.async_register_websocket_commands(hass) + supervisor_unix_socket_path: Path | None = None + if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): + socket_path = Path(socket_env) + if socket_path.is_absolute(): + supervisor_unix_socket_path = socket_path + else: + _LOGGER.error( + "Invalid Supervisor Unix socket path %s: path must be absolute", + socket_env, + ) + + def _make_server(conf: ConfData) -> HomeAssistantHTTP: + return HomeAssistantHTTP( + hass, + server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND), + server_port=conf[CONF_SERVER_PORT], + ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), + ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), + ssl_key=conf.get(CONF_SSL_KEY), + # The loaded config stores trusted proxies as strings + # (JSON-serializable); the forwarded middleware needs + # IPv4Network/IPv6Network objects. + trusted_proxies=[ + ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] + ], + ssl_profile=conf[CONF_SSL_PROFILE], + supervisor_unix_socket_path=supervisor_unix_socket_path, + ) + + server = _make_server(conf) + trial_reverted = False + while True: + try: + await server.async_bind() + except (HomeAssistantError, OSError) as err: + store = await async_get_and_load_store(hass) + trial_reverted = store.revert_deadline is not None + conf = await _async_fallback_config(hass, store, conf, err) + server = _make_server(conf) + continue + if trial_reverted: + _LOGGER.warning( + "The previous HTTP configuration has been restored (server port %d)", + conf[CONF_SERVER_PORT], + ) + break + + # Created only after the fallback chain succeeded: if setup fails above, + # an already running task would be left behind unawaited. + source_ip_task = create_eager_task(async_get_source_ip(hass)) + + async def stop_server(event: Event) -> None: + """Stop the server.""" + await server.stop() + + # Register the stop listener right away, not only once serving starts: + # sockets are already bound, and if the remainder of startup fails the + # recovery-mode teardown (which fires the stop event) must release them, + # or the recovery boot cannot bind the same address again. + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) + if CONF_SERVER_HOST in conf and is_hassio(hass): issue_id = "server_host_deprecated_hassio" ir.async_create_issue( @@ -202,60 +327,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) - ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) - ssl_key = conf.get(CONF_SSL_KEY) - cors_origins = conf[CONF_CORS_ORIGINS] - use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) - use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS] - # The loaded config stores trusted proxies as strings (JSON-serializable); - # the forwarded middleware needs IPv4Network/IPv6Network objects. - trusted_proxies = [ - ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] - ] - is_ban_enabled = conf[CONF_IP_BAN_ENABLED] - login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] - ssl_profile = conf[CONF_SSL_PROFILE] - source_ip_task = create_eager_task(async_get_source_ip(hass)) - - supervisor_unix_socket_path: Path | None = None - if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): - socket_path = Path(socket_env) - if socket_path.is_absolute(): - supervisor_unix_socket_path = socket_path - else: - _LOGGER.error( - "Invalid Supervisor Unix socket path %s: path must be absolute", - socket_env, - ) - - server = HomeAssistantHTTP( - hass, - server_host=server_host, - server_port=server_port, - ssl_certificate=ssl_certificate, - ssl_peer_certificate=ssl_peer_certificate, - ssl_key=ssl_key, - trusted_proxies=trusted_proxies, - ssl_profile=ssl_profile, - supervisor_unix_socket_path=supervisor_unix_socket_path, - ) await server.async_initialize( - cors_origins=cors_origins, - use_x_forwarded_for=use_x_forwarded_for, - login_threshold=login_threshold, - is_ban_enabled=is_ban_enabled, - use_x_frame_options=use_x_frame_options, + cors_origins=conf[CONF_CORS_ORIGINS], + use_x_forwarded_for=conf.get(CONF_USE_X_FORWARDED_FOR, False), + login_threshold=conf[CONF_LOGIN_ATTEMPTS_THRESHOLD], + is_ban_enabled=conf[CONF_IP_BAN_ENABLED], + use_x_frame_options=conf[CONF_USE_X_FRAME_OPTIONS], ) - async def stop_server(event: Event) -> None: - """Stop the server.""" - await server.stop() - async def start_server(*_: Any) -> None: """Start the server.""" with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP): - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) await server.start() async_when_setup_or_start(hass, "frontend", start_server) @@ -397,9 +480,51 @@ class HomeAssistantHTTP: self.ssl_profile = ssl_profile self.supervisor_unix_socket_path = supervisor_unix_socket_path self.runner: web.AppRunner | None = None - self.site: HomeAssistantTCPSite | None = None self.supervisor_site: HomeAssistantUnixSite | None = None self.context: ssl.SSLContext | None = None + self._server: asyncio.Server | None = None + + async def async_bind(self) -> None: + """Create the SSL context and the server, binding its sockets. + + Called during setup so that an unusable configuration surfaces before + it is applied; serving starts later in ``start()``. Raises + ``HomeAssistantError`` if the SSL configuration is unusable and + ``OSError`` if the configured address cannot be bound. + """ + if self.ssl_certificate: + self.context = await self.hass.async_add_executor_job( + self._create_ssl_context + ) + self._server = await self._async_create_server() + + async def _async_create_server(self) -> asyncio.Server: + """Create the (not yet serving) HTTP server, binding its sockets.""" + try: + return await self.hass.loop.create_server( + self._make_protocol, + self.server_host if self.server_host is not None else _DEFAULT_BIND, + self.server_port, + ssl=self.context, + backlog=128, + start_serving=False, + ) + except UnicodeError as err: + # create_server() raises UnicodeError for hosts the IDNA codec + # cannot encode (e.g. a label longer than 63 characters); + # normalize to OSError so callers only need to handle one error + # type. + raise OSError(f"error while resolving host: {err}") from err + + def _make_protocol(self) -> RequestHandler: + """Create a protocol instance for an accepted connection. + + Connections are only accepted once ``start()`` has run, so the + runner is set up by the time this is called. + """ + runner = self.runner + assert runner is not None and runner.server is not None + return runner.server() async def async_initialize( self, @@ -430,11 +555,6 @@ class HomeAssistantHTTP: setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) - if self.ssl_certificate: - self.context = await self.hass.async_add_executor_job( - self._create_ssl_context - ) - def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: """Register a view with the WSGI server. @@ -555,12 +675,13 @@ class HomeAssistantHTTP: ) context = None else: + # Fall through: a configured peer certificate must still be + # enforced on the emergency context. _LOGGER.critical( "Home Assistant is running in recovery mode with an emergency self" " signed ssl certificate because the configured SSL certificate was" " not usable" ) - return context if self.ssl_peer_certificate: if context is None: @@ -570,7 +691,15 @@ class HomeAssistantHTTP: ) context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations(self.ssl_peer_certificate) + try: + context.load_verify_locations(self.ssl_peer_certificate) + except OSError as error: + # Raise HomeAssistantError so the caller can tell an unusable + # SSL configuration apart from a socket bind failure (OSError). + raise HomeAssistantError( + f"Could not use SSL peer certificate from" + f" {self.ssl_peer_certificate}: {error}" + ) from error return context @@ -663,15 +792,10 @@ class HomeAssistantHTTP: ) await self.runner.setup() - self.site = HomeAssistantTCPSite( - self.runner, self.server_host, self.server_port, ssl_context=self.context - ) - try: - await self.site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server at port %d: %s", self.server_port, error - ) + # Setup either binds the server or fails, so it is always available + # here. + assert self._server is not None + await self._server.start_serving() _LOGGER.info("Now listening on port %d", self.server_port) @@ -690,7 +814,8 @@ class HomeAssistantHTTP: self.supervisor_unix_socket_path, err, ) - if self.site is not None: - await self.site.stop() + if self._server is not None: + self._server.close() + await self._server.wait_closed() if self.runner is not None: await self.runner.cleanup() diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py index 7564780ba643..3406ad4d793f 100644 --- a/homeassistant/components/http/config.py +++ b/homeassistant/components/http/config.py @@ -364,6 +364,18 @@ class HTTPConfigStore: await self._hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART) + async def async_abort_trial(self) -> None: + """Abort the running pending-config trial and reinstate stable. + + Called during setup when the pending config cannot be applied at all + (its address cannot be bound or its SSL configuration is unusable). + Clears the pending config so this and future starts use stable. + """ + await self.async_load() + self._async_cancel_revert() + self._pending = None + await self._async_persist() + async def async_migrate_yaml(self, config: ConfData) -> None: """Migrate YAML config to storage as pending if not the same as the config used for recovery.""" await self.async_load() diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 0348021e1382..fd07e2df66b8 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -3,74 +3,9 @@ import asyncio from pathlib import Path import socket -from ssl import SSLContext from typing import override from aiohttp import web -from yarl import URL - - -class HomeAssistantTCPSite(web.BaseSite): - """HomeAssistant specific aiohttp Site. - - Vanilla TCPSite accepts only str as host. However, the underlying asyncio's - create_server() implementation does take a list of strings to bind to multiple - host IP's. To support multiple server_host entries (e.g. to enable dual-stack - explicitly), we would like to pass an array of strings. Bring our own - implementation inspired by TCPSite. - - Custom TCPSite can be dropped when https://github.com/aio-libs/aiohttp/pull/4894 - is merged. - """ - - __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port") - - def __init__( - self, - runner: web.BaseRunner, - host: str | list[str] | None, - port: int, - *, - ssl_context: SSLContext | None = None, - backlog: int = 128, - reuse_address: bool | None = None, - reuse_port: bool | None = None, - ) -> None: - """Initialize HomeAssistantTCPSite.""" - super().__init__( - runner, - ssl_context=ssl_context, - backlog=backlog, - ) - self._host = host - self._port = port - self._reuse_address = reuse_address - self._reuse_port = reuse_port - - @property - @override - def name(self) -> str: - """Return server URL.""" - scheme = "https" if self._ssl_context else "http" - host = self._host[0] if isinstance(self._host, list) else "0.0.0.0" - return str(URL.build(scheme=scheme, host=host, port=self._port)) - - @override - async def start(self) -> None: - """Start server.""" - await super().start() - loop = asyncio.get_running_loop() - server = self._runner.server - assert server is not None - self._server = await loop.create_server( - server, - self._host, - self._port, - ssl=self._ssl_context, - backlog=self._backlog, - reuse_address=self._reuse_address, - reuse_port=self._reuse_port, - ) class HomeAssistantUnixSite(web.BaseSite): diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index a765f3a322b9..bf5dcd6c0c5f 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,13 +1,16 @@ """The tests for the Home Assistant HTTP component.""" import asyncio -from collections.abc import Callable +from collections.abc import Callable, Generator +import errno from http import HTTPStatus import logging import os from pathlib import Path +import socket +import ssl from typing import Any -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -20,11 +23,13 @@ from homeassistant.components.http.config import ( _DEFAULT_CONFIG, AUTO_REVERT_DELAY, HTTP_STORAGE_SCHEMA, + async_get_and_load_store, default_server_port, ) from homeassistant.components.http.const import ENV_SETUP_PORT -from homeassistant.const import HASSIO_USER_NAME +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError @@ -49,6 +54,49 @@ def disable_http_server(socket_enabled: None) -> None: return +# The unpatched original, for tests that exercise the real implementation. +_REAL_CREATE_SERVER = http.HomeAssistantHTTP._async_create_server + + +async def _ephemeral_server(hass: HomeAssistant) -> asyncio.Server: + """Create a bound but not serving server on an ephemeral localhost port.""" + return await hass.loop.create_server( + asyncio.Protocol, "127.0.0.1", 0, start_serving=False + ) + + +@pytest.fixture(autouse=True) +def mock_create_server() -> Generator[Mock]: + """Bind an ephemeral localhost server instead of the configured address. + + Binding the configured address for real would make parallel tests collide + on ports; an ephemeral localhost server keeps the serving path real. + """ + servers: list[asyncio.Server] = [] + + async def _bind_ephemeral(self: http.HomeAssistantHTTP) -> asyncio.Server: + server = await self.hass.loop.create_server( + self._make_protocol, + "127.0.0.1", + 0, + ssl=self.context, + start_serving=False, + ) + servers.append(server) + return server + + with patch( + "homeassistant.components.http.HomeAssistantHTTP._async_create_server", + autospec=True, + side_effect=_bind_ephemeral, + ) as mock_create: + yield mock_create + + # Close any server that is not already closed (closing twice is a no-op). + for server in servers: + server.close() + + def _setup_broken_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path]: test_dir = tmp_path / "test_broken_ssl" test_dir.mkdir() @@ -400,25 +448,30 @@ async def test_emergency_ssl_certificate_when_invalid( " certificate was not usable" in caplog.text ) - assert hass.http.site is not None + assert hass.http._server is not None async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: - """Test an emergency cert is only used in recovery mode.""" + """Test an emergency cert is only used in recovery mode. + + A broken SSL config in the stable slot fails setup (activating recovery + mode on a real boot); only recovery mode uses the emergency certificate. + """ cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - - assert ( - await async_setup_component( - hass, DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}} - ) - is False + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} ) + assert await async_setup_component(hass, DOMAIN, {}) is False + async def test_emergency_ssl_certificate_when_invalid_get_url_fails( hass: HomeAssistant, @@ -452,7 +505,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( " certificate was not usable" in caplog.text ) - assert hass.http.site is not None + assert hass.http._server is not None async def test_invalid_ssl_and_cannot_create_emergency_cert( @@ -480,7 +533,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( assert "Could not create an emergency self signed ssl certificate" in caplog.text assert len(mock_builder.mock_calls) == 1 - assert hass.http.site is not None + assert hass.http._server is not None async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( @@ -495,6 +548,9 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( an emergency cert (probably will never happen since this means the system is very broken), we do not want to startup http as it would allow connections that are not verified by the cert. + This intentionally overrides the recovery-mode fallback to the default + config: connections must never be accepted without client certificate + verification once it is configured. """ cert_path, key_path = await hass.async_add_executor_job( @@ -519,6 +575,68 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( assert len(mock_builder.mock_calls) == 1 +async def test_emergency_ssl_certificate_enforces_peer_certificate( + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], +) -> None: + """Test the emergency cert still enforces client certificate verification. + + When the configured SSL certificate is broken and recovery mode falls + back to the emergency self-signed certificate, a configured peer + certificate must still be applied - connections must never be accepted + without client certificate verification once it is configured. + """ + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmp_path + ) + hass_storage[DOMAIN] = _stable_http_storage( + { + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + "ssl_peer_certificate": str(cert_path), + } + ) + hass.config.recovery_mode = True + + with patch("ssl.SSLContext.load_verify_locations") as mock_load_verify: + assert await async_setup_component(hass, DOMAIN, {}) is True + + assert "emergency self signed ssl certificate" in caplog.text + mock_load_verify.assert_called_once_with(str(cert_path)) + assert hass.http.context is not None + assert hass.http.context.verify_mode is ssl.CERT_REQUIRED + + +async def test_create_server_passes_configuration(hass: HomeAssistant) -> None: + """The real server factory passes the configured values to asyncio.""" + server = http.HomeAssistantHTTP( + hass, + server_host=["127.0.0.1", "::1"], + server_port=1234, + ssl_certificate=None, + ssl_peer_certificate=None, + ssl_key=None, + trusted_proxies=[], + ssl_profile=http.SSL_MODERN, + ) + + with patch.object( + hass.loop, "create_server", new=AsyncMock(return_value=Mock()) + ) as mock_create: + await _REAL_CREATE_SERVER(server) + + mock_create.assert_called_once_with( + server._make_protocol, + ["127.0.0.1", "::1"], + 1234, + ssl=None, + backlog=128, + start_serving=False, + ) + + async def test_cors_defaults(hass: HomeAssistant) -> None: """Test the CORS default settings.""" with patch("homeassistant.components.http.setup_cors") as mock_setup: @@ -742,15 +860,10 @@ async def test_server_host( expected_serverhost: list, expected_issues: set[tuple[str, str]], caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, ) -> None: """Test server_host behavior.""" - mock_server = Mock() - with ( - patch("homeassistant.components.http.is_hassio", return_value=hassio), - patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server, - ): + with patch("homeassistant.components.http.is_hassio", return_value=hassio): assert await async_setup_component( hass, DOMAIN, @@ -759,15 +872,9 @@ async def test_server_host( await hass.async_start() await hass.async_block_till_done() - mock_create_server.assert_called_once_with( - ANY, - expected_serverhost, - 8123, - ssl=None, - backlog=128, - reuse_address=None, - reuse_port=None, - ) + mock_create_server.assert_called_once() + assert hass.http.server_host == expected_serverhost + assert hass.http.server_port == 8123 assert set(issue_registry.issues) == expected_issues @@ -787,7 +894,6 @@ async def test_unix_socket_started_with_supervisor( patch.dict( os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False ), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), patch( "homeassistant.components.http.web_runner.HomeAssistantUnixSite" "._create_unix_socket", @@ -812,7 +918,6 @@ async def test_unix_socket_not_started_without_supervisor( """Test unix socket is not started when not running under Supervisor.""" with ( patch.dict(os.environ, {}, clear=False), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None) assert await async_setup_component(hass, DOMAIN, {"http": {}}) @@ -833,7 +938,6 @@ async def test_unix_socket_rejected_relative_path( {"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"}, clear=False, ), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): assert await async_setup_component(hass, DOMAIN, {"http": {}}) await hass.async_start() @@ -861,10 +965,9 @@ async def test_yaml_migration_to_storage( "trusted_proxies": ["127.0.0.0/8"], "ip_ban_enabled": False, } - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") assert issue is not None @@ -918,10 +1021,9 @@ async def test_yaml_migration_matches_stable_no_pending( "trusted_proxies": ["127.0.0.0/8"], "ip_ban_enabled": False, } - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() stored = hass_storage[DOMAIN]["data"] assert stored["pending"] is None @@ -956,10 +1058,9 @@ async def test_yaml_migration_differs_from_stable_creates_pending( } yaml_conf = {"server_port": 8765, "ip_ban_enabled": False} - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() stored = hass_storage[DOMAIN]["data"] assert stored["stable"] == existing_stable @@ -984,7 +1085,6 @@ async def test_yaml_migration_failure_creates_error_issue( yaml_conf = {"server_port": 9123} with ( - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), patch( "homeassistant.components.http.config.HTTPConfigStore.async_migrate_yaml", side_effect=RuntimeError("boom"), @@ -1012,17 +1112,12 @@ async def test_yaml_still_present_after_migration_creates_issue( ) yaml_conf = {"server_port": 1234} - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() # YAML must be ignored once migration is done; stable wins. - args, _ = mock_create_server.call_args - assert args[2] == 9876 + assert hass.config.api.port == 9876 issue = issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") assert issue is not None @@ -1047,10 +1142,9 @@ async def test_yaml_still_present_issue_cleared_when_yaml_removed( translation_key="yaml_still_present_after_migration", ) - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() assert ( issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") @@ -1071,16 +1165,11 @@ async def test_setup_uses_stable_config_when_no_yaml( } ) - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 9876 + assert hass.config.api.port == 9876 assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None assert ( @@ -1097,16 +1186,11 @@ async def test_setup_prefers_pending_over_stable_in_normal_mode( {"server_port": 9876}, pending={"server_port": 9999} ) - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 9999 + assert hass.config.api.port == 9999 async def test_recovery_mode_falls_back_to_stable( @@ -1119,16 +1203,11 @@ async def test_recovery_mode_falls_back_to_stable( ) hass.config.recovery_mode = True - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 9876 + assert hass.config.api.port == 9876 async def test_recovery_mode_with_no_storage( @@ -1145,16 +1224,11 @@ async def test_recovery_mode_with_no_storage( assert "http" not in hass_storage hass.config.recovery_mode = True - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 8123 + assert hass.config.api.port == 8123 # Recovery mode must not trigger YAML migration side effects. assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None @@ -1175,19 +1249,12 @@ async def test_recovery_mode_ignores_yaml( ) hass.config.recovery_mode = True - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component( - hass, DOMAIN, {"http": {"server_port": 1234}} - ) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {"http": {"server_port": 1234}}) + await hass.async_start() + await hass.async_block_till_done() - args, _ = mock_create_server.call_args # YAML's port must NOT win: stable is the only source of truth in recovery. - assert args[2] == 5555 + assert hass.config.api.port == 5555 # The migration must not run in recovery mode, so its flag stays untouched # and no deprecation issue is created on this boot. assert hass_storage[DOMAIN]["data"]["yaml_migration_done"] is False @@ -1205,19 +1272,14 @@ async def test_setup_migrates_v1_storage_to_v2( "data": {"server_port": 9876}, } - mock_server = Mock() - with patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() # The migrated v1 store config is only used in recovery mode. Since this # test isn't running in recovery mode, the YAML migration runs on first # boot after store migration. With no YAML http config, the default config is migrated to the pending slot and used. Therefore we assert below the default port (8123) - args, _ = mock_create_server.call_args - assert args[2] == 8123 + assert hass.config.api.port == 8123 assert hass_storage[DOMAIN]["version"] == 2 data = hass_storage[DOMAIN]["data"] # The v1→v2 migration normalises the payload through the storage schema, @@ -1252,19 +1314,14 @@ async def test_setup_port_env_var_used_as_default( hass_storage: dict[str, Any], ) -> None: """Test SETUP_PORT is used as the default server port without YAML config.""" - mock_server = Mock() with ( patch.dict(os.environ, {ENV_SETUP_PORT: "80"}), - patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server, ): assert await async_setup_component(hass, "http", {}) await hass.async_start() await hass.async_block_till_done() - args, _ = mock_create_server.call_args - assert args[2] == 80 + assert hass.config.api.port == 80 assert hass_storage["http"]["data"]["pending"]["server_port"] == 80 @@ -1274,11 +1331,10 @@ async def test_websocket_http_config( hass_storage: dict[str, Any], ) -> None: """Test the http/config, configure and promote websocket commands.""" - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) @@ -1399,11 +1455,10 @@ async def test_pending_config_auto_reverts_to_stable( # The revert deadline is anchored to the (frozen) load time. revert_at = dt_util.utcnow() + AUTO_REVERT_DELAY - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) @@ -1432,6 +1487,335 @@ async def test_pending_config_auto_reverts_to_stable( assert len(restart_calls) == 1 +@pytest.mark.parametrize( + "bind_error", + [ + OSError(errno.EADDRINUSE, "Address already in use"), + PermissionError(errno.EACCES, "Permission denied"), + socket.gaierror(socket.EAI_NONAME, "Name or service not known"), + ], + ids=["address-in-use", "permission-denied", "unresolvable-host"], +) +async def test_pending_config_reverted_in_place_on_bind_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, + bind_error: OSError, +) -> None: + """A pending config that cannot be bound is reverted within the same start. + + The trial fails while the config is realized during setup, so the stable + config is applied in place - no restart, no waiting out the trial window. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 80} + ) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + stable_server = await _ephemeral_server(hass) + mock_create_server.side_effect = [bind_error, stable_server] + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # The pending config is dropped and this same start continues on stable. + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}), + "pending": None, + "yaml_migration_done": True, + } + assert hass.config.api is not None + assert hass.config.api.port == 9876 + # The second bind attempt was for the stable config. + assert mock_create_server.call_args_list[1].args[0].server_port == 9876 + # No restart is involved and no revert stays scheduled. + assert len(restart_calls) == 0 + store = await async_get_and_load_store(hass) + assert store.revert_deadline is None + assert "could not be applied, reverting" in caplog.text + assert "previous HTTP configuration has been restored (server port 9876)" in ( + caplog.text + ) + stable_server.close() + await stable_server.wait_closed() + + +async def test_pending_config_reverted_in_place_on_ssl_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """A pending config whose SSL certificate is unusable reverts in place.""" + stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876})) + # Craft the raw storage payload: the schema validates that the SSL files + # exist when the config is set, but they can vanish before the next start. + pending = dict(HTTP_STORAGE_SCHEMA({"server_port": 9999})) + pending["ssl_certificate"] = "/nonexistent/cert.pem" + pending["ssl_key"] = "/nonexistent/key.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": pending, "yaml_migration_done": True}, + } + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"]["pending"] is None + assert hass.config.api is not None + assert hass.config.api.port == 9876 + assert hass.config.api.use_ssl is False + assert len(restart_calls) == 0 + + +async def test_pending_config_reverted_in_place_on_ssl_peer_cert_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], + tmp_path: Path, +) -> None: + """A pending config whose SSL peer certificate is unusable reverts in place.""" + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876})) + pending = dict( + HTTP_STORAGE_SCHEMA( + { + "server_port": 9999, + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + } + ) + ) + # The peer certificate vanished after the config was stored. + pending["ssl_peer_certificate"] = "/nonexistent/peer.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": pending, "yaml_migration_done": True}, + } + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + with patch("ssl.SSLContext.load_cert_chain"): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"]["pending"] is None + assert hass.config.api is not None + assert hass.config.api.port == 9876 + assert hass.config.api.use_ssl is False + assert len(restart_calls) == 0 + + +async def test_stable_config_ssl_peer_cert_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + tmp_path: Path, +) -> None: + """A stable config whose SSL peer certificate is unusable fails setup. + + An unusable stable SSL configuration must fail setup, activating recovery + mode on a real boot. + """ + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + stable = dict( + HTTP_STORAGE_SCHEMA( + { + "server_port": 9876, + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + } + ) + ) + stable["ssl_peer_certificate"] = "/nonexistent/peer.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": None, "yaml_migration_done": True}, + } + + with patch("ssl.SSLContext.load_cert_chain"): + assert await async_setup_component(hass, DOMAIN, {}) is False + + +async def test_bound_server_closed_on_stop_before_start( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """A bound server is closed on stop even if it never started serving. + + If setup fails after binding (or recovery mode tears Home Assistant down + before serving starts), the stop event must close the server so a + follow-up boot in the same process can bind the address again. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 9876}) + + server = await _ephemeral_server(hass) + mock_create_server.side_effect = [server] + + with patch.object( + http.HomeAssistantHTTP, + "async_initialize", + side_effect=HomeAssistantError("Setup failed after binding"), + ): + assert not await async_setup_component(hass, DOMAIN, {}) + + assert server.sockets + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert not server.sockets + + +async def test_stable_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """A stable config that cannot be bound fails setup. + + Failing setup activates recovery mode on a real boot, which retries with + the stable config and falls back to the default config, so Home Assistant + stays reachable. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert len(restart_calls) == 0 + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 80}), + "pending": None, + "yaml_migration_done": True, + } + + +async def test_pending_and_stable_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """Setup fails when the trialed pending and the stable config cannot bind. + + The pending config must already be cleared and persisted, so the recovery + boot and future normal starts use stable instead of re-trialing it. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 80} + ) + + mock_create_server.side_effect = [ + OSError(errno.EADDRINUSE, "Address already in use"), + OSError(errno.EADDRINUSE, "Address already in use"), + ] + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert hass_storage["http"]["data"]["pending"] is None + + +async def test_create_server_normalizes_unencodable_host( + hass: HomeAssistant, +) -> None: + """A host name the IDNA codec cannot encode raises OSError. + + create_server() raises UnicodeError (a ValueError) for such host names, + e.g. a label longer than 63 characters; it must be normalized to OSError + so the config fallback chain handles it like any other bind failure. + """ + server = http.HomeAssistantHTTP( + hass, + server_host=[f"{'x' * 64}.example"], + server_port=8123, + ssl_certificate=None, + ssl_peer_certificate=None, + ssl_key=None, + trusted_proxies=[], + ssl_profile=http.SSL_MODERN, + ) + with ( + patch.object( + hass.loop, + "create_server", + side_effect=UnicodeError( + "encoding with 'idna' codec failed (UnicodeError: label too long)" + ), + ), + pytest.raises(OSError, match="error while resolving host"), + ): + await _REAL_CREATE_SERVER(server) + + +async def test_recovery_mode_bind_failure_falls_back_to_default_config( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, +) -> None: + """In recovery mode an unbindable stable config falls back to defaults. + + Recovery mode is the last resort and must not fail setup again, so the + default config is applied in place to keep the recovery UI reachable. + The stable config is left untouched. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + hass.config.recovery_mode = True + + default_server = await _ephemeral_server(hass) + mock_create_server.side_effect = [ + OSError(errno.EADDRINUSE, "Address already in use"), + default_server, + ] + + assert await async_setup_component(hass, DOMAIN, {}) + + assert "falling back to the default configuration" in caplog.text + assert hass.config.api is not None + assert hass.config.api.port == default_server_port() + # The second bind attempt was for the default config. + assert mock_create_server.call_args_list[1].args[0].server_port == ( + default_server_port() + ) + assert hass_storage["http"]["data"]["stable"] == HTTP_STORAGE_SCHEMA( + {"server_port": 80} + ) + default_server.close() + await default_server.wait_closed() + + +async def test_recovery_mode_default_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, +) -> None: + """Setup fails in recovery mode when even the default config cannot bind. + + The fallback chain is exhausted; failing setup makes the failure visible + to the outside (e.g. the Supervisor rolls back a Core update whose API + does not come up). + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + hass.config.recovery_mode = True + + mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert f"Failed to create HTTP server at port {default_server_port()}" in ( + caplog.text + ) + + async def test_pending_config_promote_cancels_revert( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1445,11 +1829,10 @@ async def test_pending_config_promote_cancels_revert( restart_calls = async_mock_service(hass, "homeassistant", "restart") - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) @@ -1497,11 +1880,10 @@ async def test_websocket_http_config_invalid( config: dict, ) -> None: """Test that an invalid HTTP config is rejected.""" - with patch("asyncio.BaseEventLoop.create_server", return_value=Mock()): - assert await async_setup_component(hass, "http", {}) - await async_setup_component(hass, "websocket_api", {}) - await hass.async_start() - await hass.async_block_till_done() + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() ws_client = await hass_ws_client(hass) diff --git a/tests/conftest.py b/tests/conftest.py index f8e7e37bc53c..92a94c4f04de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2254,8 +2254,11 @@ DhcpServiceInfo.__init__ = _dhcp_service_info_init def disable_http_server() -> Generator[None]: """Disable automatic start of HTTP server during tests. - This prevents the HTTP server from starting in tests that setup - integrations which depend on the HTTP component. + This prevents the HTTP server from binding sockets and starting in tests + that setup integrations which depend on the HTTP component. """ - with patch("homeassistant.components.http.HomeAssistantHTTP.start"): + with ( + patch("homeassistant.components.http.HomeAssistantHTTP.async_bind"), + patch("homeassistant.components.http.HomeAssistantHTTP.start"), + ): yield From e9be62fe77f221bf315411ec4d6514102a4f6c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Thu, 16 Jul 2026 11:38:54 +0200 Subject: [PATCH 644/707] Raise nobo_hub quality scale to gold (#176480) --- homeassistant/components/nobo_hub/icons.json | 17 ++++++++++++++ .../components/nobo_hub/manifest.json | 2 +- .../components/nobo_hub/quality_scale.yaml | 22 ++++++++++++------- 3 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/nobo_hub/icons.json diff --git a/homeassistant/components/nobo_hub/icons.json b/homeassistant/components/nobo_hub/icons.json new file mode 100644 index 000000000000..74c20b18fe5b --- /dev/null +++ b/homeassistant/components/nobo_hub/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "select": { + "global_override": { + "default": "mdi:calendar-clock", + "state": { + "away": "mdi:account-arrow-right", + "comfort": "mdi:sofa", + "eco": "mdi:leaf" + } + }, + "week_profile": { + "default": "mdi:calendar-clock" + } + } + } +} diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index a098ec5a6607..3350742c38d5 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -15,6 +15,6 @@ "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["pynobo==1.9.0"] } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 6ad1081c7d7b..fb94595ed48f 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -11,7 +11,7 @@ rules: dependency-transparency: done docs-actions: status: exempt - comment: Integration does not register custom actions. + comment: This integration does not register custom actions. docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -59,21 +59,27 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: done - entity-category: todo + entity-category: + status: exempt + comment: > + All entities are primary controls or measurements; none are configuration + or diagnostic entities that need a non-default entity category. entity-device-class: done - entity-disabled-by-default: todo - entity-translations: todo - exception-translations: todo - icon-translations: todo + entity-disabled-by-default: + status: exempt + comment: This integration has no entities that should be disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done reconfiguration-flow: done repair-issues: status: exempt - comment: Integration has no repair scenarios. + comment: This integration has no repair scenarios. stale-devices: done # Platinum async-dependency: done inject-websession: status: exempt - comment: Integration uses a local TCP socket (via pynobo); no HTTP client is used. + comment: This integration uses a local TCP socket (via pynobo); no HTTP client is used. strict-typing: todo From 2416d13dc5802e9b6cf06394cb108770c82fe611 Mon Sep 17 00:00:00 2001 From: Markus Tuominen <3738613+Markus98@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:05:15 +0200 Subject: [PATCH 645/707] Use plain Exception for scaffold config flow errors (#176597) --- .../templates/config_flow/integration/config_flow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 19b2ab406674..226f375c2fbd 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN @@ -94,9 +93,9 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" From 02b7100c921cb3c0396f666d88a8bc7704681b88 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 16 Jul 2026 13:21:57 +0200 Subject: [PATCH 646/707] Bump aioshelly to 13.27.0 (#176605) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 02f5979e2158..93d326bc33fe 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.26.2"], + "requirements": ["aioshelly==13.27.0"], "zeroconf": [ { "name": "shelly*", diff --git a/requirements_all.txt b/requirements_all.txt index 603043347a0b..d52d23013116 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ aiorussound==5.0.1 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.26.2 +aioshelly==13.27.0 # homeassistant.components.skybell aioskybell==22.7.0 From d5e988a0215ca65d0f2f5c9dc9f318d8453531c7 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 16 Jul 2026 14:12:40 +0200 Subject: [PATCH 647/707] Refactor config flow with Exception in Fireflyy III (#176601) --- homeassistant/components/firefly_iii/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py index 8f84da1c1cf6..f3684930bef0 100644 --- a/homeassistant/components/firefly_iii/config_flow.py +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -15,7 +15,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -174,13 +173,13 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class FireflyClientTimeout(HomeAssistantError): +class FireflyClientTimeout(Exception): """Error to indicate a timeout occurred.""" From 06591972ad28e40c8abc366dc63dd0c0b6cb57ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Thu, 16 Jul 2026 14:14:47 +0200 Subject: [PATCH 648/707] Catch PynoboConnectionError at nobo_hub connection sites (#176604) --- homeassistant/components/nobo_hub/__init__.py | 6 ++-- .../components/nobo_hub/config_flow.py | 8 ++--- tests/components/nobo_hub/test_config_flow.py | 17 +++++----- tests/components/nobo_hub/test_init.py | 31 ++++++++++++++++--- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index faed74a2a16f..1066d306349d 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -2,7 +2,7 @@ import logging -from pynobo import nobo +from pynobo import PynoboConnectionError, nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b try: hub = await _connect(stored_ip) - except OSError as err: + except PynoboConnectionError as err: # Stored IP may be stale - try UDP rediscovery to pick up a new # DHCP lease (or a hub that's been moved). discovered = await nobo.async_discover_hubs(serial=serial) @@ -66,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b new_ip, _ = next(iter(discovered)) try: hub = await _connect(new_ip) - except OSError as rediscover_err: + except PynoboConnectionError as rediscover_err: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 8df9940493d3..ecbd23a7d487 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -3,7 +3,7 @@ import ipaddress from typing import TYPE_CHECKING, Any, override -from pynobo import nobo +from pynobo import PynoboConnectionError, nobo import voluptuous as vol from homeassistant.config_entries import ( @@ -313,14 +313,14 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): raise NoboHubConnectError("invalid_ip") from err hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) # pynobo distinguishes the two failure modes: TCP-level errors - # (wrong IP, hub offline, port closed) raise OSError, while a - # successful TCP connection followed by a handshake REJECT + # (wrong IP, hub offline, port closed) raise PynoboConnectionError, + # while a successful TCP connection followed by a handshake REJECT # (serial mismatch) returns False. try: if not await hub.async_connect_hub(ip_address, serial): raise NoboHubConnectError("cannot_connect") return hub.hub_info["name"] - except OSError as err: + except PynoboConnectionError as err: raise NoboHubConnectError("cannot_connect_ip") from err finally: await hub.close() diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py index a62e84bb0a92..14fe846426a6 100644 --- a/tests/components/nobo_hub/test_config_flow.py +++ b/tests/components/nobo_hub/test_config_flow.py @@ -1,8 +1,8 @@ """Test the Nobø Ecohub config flow.""" -import errno from unittest.mock import AsyncMock, PropertyMock, patch +from pynobo import PynoboConnectionError import pytest from homeassistant import config_entries @@ -407,7 +407,10 @@ async def test_configure_invalid_ip_address( ("connect_outcome", "expected_error"), [ ({"return_value": False}, "cannot_connect"), - ({"side_effect": ConnectionRefusedError(61, "")}, "cannot_connect_ip"), + ( + {"side_effect": PynoboConnectionError("Failed to connect")}, + "cannot_connect_ip", + ), ], ids=["serial_mismatch", "tcp_failure"], ) @@ -420,10 +423,10 @@ async def test_configure_cannot_connect( """Connect failures map to distinct error keys; retry recovers. pynobo's async_connect_hub returns False on a successful TCP connect - followed by a handshake REJECT (serial mismatch) and raises OSError - on TCP-level failure (wrong IP / hub offline). We surface these as - cannot_connect ("check serial number") and cannot_connect_ip - ("check IP address") respectively. + followed by a handshake REJECT (serial mismatch) and raises + PynoboConnectionError on TCP-level failure (wrong IP / hub offline). + We surface these as cannot_connect ("check serial number") and + cannot_connect_ip ("check IP address") respectively. """ with patch( "homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs", @@ -818,7 +821,7 @@ async def test_reconfigure_flow_changes_ip( [ ( "192.168.1.200", - {"side_effect": ConnectionRefusedError(errno.ECONNREFUSED, "")}, + {"side_effect": PynoboConnectionError("Failed to connect")}, "cannot_connect_ip", 1, ), diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 9a8d8c005b87..f89c50b5bffc 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -3,7 +3,7 @@ import logging from unittest.mock import MagicMock -from pynobo import nobo as pynobo_nobo +from pynobo import PynoboConnectionError, nobo as pynobo_nobo import pytest from homeassistant.components.nobo_hub.const import ( @@ -61,7 +61,7 @@ async def test_setup_rediscovery_updates_ip( """A failed direct connect falls back to rediscovery and persists the new IP.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub, mock_nobo_class.return_value] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -83,7 +83,7 @@ async def test_setup_retries_when_rediscovery_finds_nothing( """Setup retries when stored IP fails and rediscovery is empty.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub] mock_nobo_class.async_discover_hubs.return_value = set() @@ -106,9 +106,9 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( """Setup retries when both stored and rediscovered IPs fail.""" mock_config_entry.add_to_hass(hass) first_failing_hub = MagicMock(spec=pynobo_nobo) - first_failing_hub.connect.side_effect = OSError("Unreachable") + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") second_failing_hub = MagicMock(spec=pynobo_nobo) - second_failing_hub.connect.side_effect = OSError("Unreachable") + second_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -123,6 +123,27 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( } +async def test_setup_does_not_catch_plain_os_error_on_rediscovered_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, +) -> None: + """A plain OSError from the rediscovered IP is not caught by the fallback.""" + mock_config_entry.add_to_hass(hass) + first_failing_hub = MagicMock(spec=pynobo_nobo) + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") + second_failing_hub = MagicMock(spec=pynobo_nobo) + second_failing_hub.connect.side_effect = OSError("boom") + mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] + mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_nobo_class.call_count == 2 + + @pytest.mark.parametrize( ("stored_options", "expected_options"), [ From f58bc989a0a306ea61a4cefeb6cd8df9999b55c0 Mon Sep 17 00:00:00 2001 From: Markus Tuominen <3738613+Markus98@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:22:13 +0200 Subject: [PATCH 649/707] Remove redundant block_till_done from scaffold config flow tests (#176607) --- .../scaffold/templates/config_flow/tests/test_config_flow.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 66209f77e6a1..e2d8952396b1 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -30,7 +30,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -81,7 +80,6 @@ async def test_form_invalid_auth( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -133,7 +131,6 @@ async def test_form_cannot_connect( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" From a337bd026360fc6bb72755ee92d91585f08fae9d Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 16 Jul 2026 14:23:03 +0200 Subject: [PATCH 650/707] Refactor config flow with Exception in Portainer (#176600) --- homeassistant/components/portainer/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 2037ab45cac9..8aa037d7b386 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, @@ -198,13 +197,13 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class PortainerTimeout(HomeAssistantError): +class PortainerTimeout(Exception): """Error to indicate a timeout occurred.""" From e63f227dd3a14e8f2fa1105c8be4d5f11ccc3f5c Mon Sep 17 00:00:00 2001 From: Amit Krishna <218109745+amitkio@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:55:07 +0530 Subject: [PATCH 651/707] Add diagnostics support to energieleser (#176552) --- .../components/energieleser/diagnostics.py | 25 ++++ .../energieleser/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 125 ++++++++++++++++++ .../energieleser/test_diagnostics.py | 55 ++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100755 homeassistant/components/energieleser/diagnostics.py create mode 100755 tests/components/energieleser/snapshots/test_diagnostics.ambr create mode 100755 tests/components/energieleser/test_diagnostics.py diff --git a/homeassistant/components/energieleser/diagnostics.py b/homeassistant/components/energieleser/diagnostics.py new file mode 100755 index 000000000000..792297e8c58b --- /dev/null +++ b/homeassistant/components/energieleser/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics support for energieleser.""" + +import dataclasses +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant + +from .coordinator import EnergieleserConfigEntry + +TO_REDACT = {CONF_DEVICE_ID, "fabrication_number"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: EnergieleserConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + device_data = coordinator.data + + device_data_dict = dataclasses.asdict(device_data) + + return async_redact_data(device_data_dict, TO_REDACT) diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index 0a83fc235811..ea4d7d13f731 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo diff --git a/tests/components/energieleser/snapshots/test_diagnostics.ambr b/tests/components/energieleser/snapshots/test_diagnostics.ambr new file mode 100755 index 000000000000..863d1f582cb8 --- /dev/null +++ b/tests/components/energieleser/snapshots/test_diagnostics.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_entry_diagnostics[gasleser] + dict({ + 'count': 603, + 'current_flow_rate': 0.01, + 'device_id': '**REDACTED**', + 'device_type': 'gasleser', + 'signal_strength_dbm': -51.0, + 'timestamp': 1776179005, + 'total_consumption': 37030.67, + }) +# --- +# name: test_entry_diagnostics[stromleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'stromleser', + 'energy_export': dict({ + 'unit': 'Wh', + 'value': 26561.0, + }), + 'energy_export_tariff_1': None, + 'energy_export_tariff_2': None, + 'energy_export_tariff_3': None, + 'energy_export_tariff_4': None, + 'energy_import': dict({ + 'unit': 'Wh', + 'value': 12345.0, + }), + 'energy_import_tariff_1': None, + 'energy_import_tariff_2': None, + 'energy_import_tariff_3': None, + 'energy_import_tariff_4': None, + 'pin_locked': False, + 'power_absolute': None, + 'power_active': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'power_export': None, + 'power_import': None, + 'power_l1': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l2': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l3': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'signal_strength_dbm': -51.0, + 'timestamp': 1776178480, + }) +# --- +# name: test_entry_diagnostics[waermeleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'waermeleser', + 'fabrication_number': '**REDACTED**', + 'flow_temperature': dict({ + 'unit': '°C', + 'value': 16.9, + }), + 'power': dict({ + 'unit': 'kW', + 'value': 2.31, + }), + 'return_temperature': dict({ + 'unit': '°C', + 'value': 19.6, + }), + 'signal_strength_dbm': -51.0, + 'temperature_difference': dict({ + 'unit': 'K', + 'value': 2.68, + }), + 'timestamp': 1747285200, + 'total_energy_t1': dict({ + 'unit': 'MWh', + 'value': 34.09, + }), + 'total_energy_t2': dict({ + 'unit': 'MWh', + 'value': 12.45, + }), + 'total_energy_t3': dict({ + 'unit': 'MWh', + 'value': 5.67, + }), + 'total_volume': dict({ + 'unit': 'm³', + 'value': 3561.23, + }), + 'volume_flow': dict({ + 'unit': 'l/h', + 'value': 1.23, + }), + }) +# --- +# name: test_entry_diagnostics[wasserleser] + dict({ + 'current_flow_rate': dict({ + 'unit': 'l/h', + 'value': 0.0, + }), + 'current_flow_rate_m3': dict({ + 'unit': 'm3/h', + 'value': 0.0, + }), + 'device_id': '**REDACTED**', + 'device_type': 'wasserleser', + 'signal_strength_dbm': -49.0, + 'timestamp': 1779276532, + 'today_consumption': dict({ + 'unit': 'm3', + 'value': 0.0, + }), + 'total_consumption': dict({ + 'unit': 'm3', + 'value': 123.755, + }), + }) +# --- diff --git a/tests/components/energieleser/test_diagnostics.py b/tests/components/energieleser/test_diagnostics.py new file mode 100755 index 000000000000..61795c4615af --- /dev/null +++ b/tests/components/energieleser/test_diagnostics.py @@ -0,0 +1,55 @@ +"""Test energieleser diagnostics.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("device_fixture", "config_entry_fixture"), + [ + pytest.param( + "mock_stromleser_device", "mock_stromleser_config_entry", id="stromleser" + ), + pytest.param( + "mock_gasleser_device", "mock_gasleser_config_entry", id="gasleser" + ), + pytest.param( + "mock_waermeleser_device", + "mock_waermeleser_config_entry", + id="waermeleser", + ), + pytest.param( + "mock_wasserleser_device", + "mock_wasserleser_config_entry", + id="wasserleser", + ), + ], +) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_energieleser_client: AsyncMock, + device_fixture: str, + config_entry_fixture: str, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + device = request.getfixturevalue(device_fixture) + config_entry = request.getfixturevalue(config_entry_fixture) + + mock_energieleser_client.get_device.return_value = device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot From 00be4a89049e67d6d76414ddd18f6b913a02dd49 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Thu, 16 Jul 2026 16:44:48 +0200 Subject: [PATCH 652/707] Bump python-duco-connectivity to 0.9.0 (#176615) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index f3806627f2a7..49ee92e04bd4 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.8.0"], + "requirements": ["python-duco-connectivity==0.9.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/requirements_all.txt b/requirements_all.txt index d52d23013116..e7521e0cb174 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2655,7 +2655,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.8.0 +python-duco-connectivity==0.9.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 From a55511f3fb008e6669326a14f16ad7555e85b5ce Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Jul 2026 16:48:47 +0200 Subject: [PATCH 653/707] Remove Modbus Connection integration (#176618) Co-authored-by: Claude Fable 5 --- .strict-typing | 1 - CODEOWNERS | 2 - .../components/modbus_connection/__init__.py | 99 ------------ .../modbus_connection/config_flow.py | 126 --------------- .../components/modbus_connection/const.py | 21 --- .../modbus_connection/exceptions.py | 25 --- .../modbus_connection/manifest.json | 13 -- .../modbus_connection/quality_scale.yaml | 119 --------------- .../components/modbus_connection/strings.json | 62 -------- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - mypy.ini | 10 -- requirements_all.txt | 3 - .../components/modbus_connection/__init__.py | 1 - .../components/modbus_connection/conftest.py | 63 -------- .../modbus_connection/test_config_flow.py | 143 ------------------ .../components/modbus_connection/test_init.py | 128 ---------------- 17 files changed, 823 deletions(-) delete mode 100644 homeassistant/components/modbus_connection/__init__.py delete mode 100644 homeassistant/components/modbus_connection/config_flow.py delete mode 100644 homeassistant/components/modbus_connection/const.py delete mode 100644 homeassistant/components/modbus_connection/exceptions.py delete mode 100644 homeassistant/components/modbus_connection/manifest.json delete mode 100644 homeassistant/components/modbus_connection/quality_scale.yaml delete mode 100644 homeassistant/components/modbus_connection/strings.json delete mode 100644 tests/components/modbus_connection/__init__.py delete mode 100644 tests/components/modbus_connection/conftest.py delete mode 100644 tests/components/modbus_connection/test_config_flow.py delete mode 100644 tests/components/modbus_connection/test_init.py diff --git a/.strict-typing b/.strict-typing index 400f8d1f32ed..8f1239d45665 100644 --- a/.strict-typing +++ b/.strict-typing @@ -383,7 +383,6 @@ homeassistant.components.min_max.* homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* -homeassistant.components.modbus_connection.* homeassistant.components.modem_callerid.* homeassistant.components.mold_indicator.* homeassistant.components.monzo.* diff --git a/CODEOWNERS b/CODEOWNERS index 93d8fcdbefdb..8ce2921396d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1148,8 +1148,6 @@ CLAUDE.md @home-assistant/core /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus_connection/ @home-assistant/core -/tests/components/modbus_connection/ @home-assistant/core /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/modbus_connection/__init__.py b/homeassistant/components/modbus_connection/__init__.py deleted file mode 100644 index c09aca8ba8a3..000000000000 --- a/homeassistant/components/modbus_connection/__init__.py +++ /dev/null @@ -1,99 +0,0 @@ -"""The Modbus Connection integration.""" - -from collections.abc import Mapping -from typing import Any, cast - -from modbus_connection import ModbusConnection, ModbusError, ModbusUnit -from modbus_connection.tmodbus import connect_serial, connect_tcp - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - DOMAIN, -) -from .exceptions import ConnectionNotReady - -__all__ = ["ConnectionNotReady", "async_get_unit"] - -type ModbusConnectionConfigEntry = ConfigEntry[ModbusConnection] - - -async def _async_open(data: Mapping[str, Any]) -> ModbusConnection: - """Open the connection described by ``data`` (transport parameters). - - Shared by config-entry setup and the config flow's validation; the caller - owns the returned connection and closes it. - """ - if data[CONF_TYPE] == CONNECTION_SERIAL: - return await connect_serial( - data[CONF_DEVICE], - baudrate=data[CONF_BAUDRATE], - bytesize=data[CONF_BYTESIZE], - parity=data[CONF_PARITY], - stopbits=data[CONF_STOPBITS], - ) - return await connect_tcp(data[CONF_HOST], port=data[CONF_PORT]) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ModbusConnectionConfigEntry -) -> bool: - """Set up a Modbus connection from a config entry.""" - try: - connection = await _async_open(entry.data) - except ModbusError as err: - raise ConfigEntryNotReady(f"Could not open Modbus connection: {err}") from err - - entry.runtime_data = connection - - # The connection is transient and does not self-reconnect: on a drop, reload - # this entry. HA's ConfigEntryNotReady retry is the reconnect backoff. - entry.async_on_unload( - connection.on_connection_lost( - lambda: hass.config_entries.async_schedule_reload(entry.entry_id) - ) - ) - - return True - - -async def async_unload_entry( - hass: HomeAssistant, entry: ModbusConnectionConfigEntry -) -> bool: - """Unload a config entry and close the owned connection.""" - await entry.runtime_data.close() - return True - - -@callback -def async_get_unit( - hass: HomeAssistant, connection_entry_id: str, unit_id: int -) -> ModbusUnit: - """Return a Modbus unit on a shared connection. - - Consumer integrations call this to borrow a ``ModbusUnit`` bound to their - unit ID; the ``ModbusConnection`` itself never leaves this integration. - - Raises ``ValueError`` if ``connection_entry_id`` is unknown or does not point - at a ``modbus_connection`` entry (a programming error in the consumer). Raises - ``ConnectionNotReady`` if that entry exists but is not loaded; it is a - ``ConfigEntryNotReady``, so a consumer can let it propagate from its own - ``async_setup_entry`` to get Home Assistant's setup retry. - """ - entry = cast( - "ModbusConnectionConfigEntry | None", - hass.config_entries.async_get_entry(connection_entry_id), - ) - if entry is None or entry.domain != DOMAIN: - raise ValueError(f"{connection_entry_id} is not a modbus_connection entry") - if entry.state is not ConfigEntryState.LOADED: - raise ConnectionNotReady(connection_entry_id) - return entry.runtime_data.for_unit(unit_id) diff --git a/homeassistant/components/modbus_connection/config_flow.py b/homeassistant/components/modbus_connection/config_flow.py deleted file mode 100644 index dd0e3adc8ae5..000000000000 --- a/homeassistant/components/modbus_connection/config_flow.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Config flow for the Modbus Connection integration.""" - -from typing import Any, override - -from modbus_connection import ModbusError -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.helpers.selector import ( - SelectSelector, - SelectSelectorConfig, - SelectSelectorMode, - SerialPortSelector, -) - -from . import _async_open -from .const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DEFAULT_BAUDRATE, - DEFAULT_BYTESIZE, - DEFAULT_PARITY, - DEFAULT_PORT, - DEFAULT_STOPBITS, - DOMAIN, -) - -STEP_MODBUS_TCP = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( - vol.Coerce(int), vol.Range(min=1, max=65535) - ), - } -) - -# SerialPortSelector lists local serial ports and network serial proxies. -STEP_SERIAL = vol.Schema( - { - vol.Required(CONF_DEVICE): SerialPortSelector(), - vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - vol.Required(CONF_PARITY, default=DEFAULT_PARITY): SelectSelector( - SelectSelectorConfig( - options=["n", "e", "o"], - translation_key="parity", - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Required(CONF_STOPBITS, default=DEFAULT_STOPBITS): vol.In([1, 2]), - vol.Required(CONF_BYTESIZE, default=DEFAULT_BYTESIZE): vol.In([7, 8]), - } -) - - -class ModbusConnectionConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Modbus Connection.""" - - VERSION = 1 - - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Let the user choose the transport.""" - return self.async_show_menu( - step_id="user", - menu_options=["modbus_tcp", "serial"], - ) - - async def async_step_modbus_tcp( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure a Modbus TCP / RTU-over-TCP connection.""" - errors: dict[str, str] = {} - if user_input is not None: - data = {CONF_TYPE: CONNECTION_TCP, **user_input} - # Dedupe before opening: most Modbus devices reject a second client. - self._async_abort_entries_match(data) - if not (errors := await self._async_validate(data)): - return self.async_create_entry( - title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data - ) - return self.async_show_form( - step_id="modbus_tcp", data_schema=STEP_MODBUS_TCP, errors=errors - ) - - async def async_step_serial( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure a Modbus serial (RTU) connection, incl. network serial proxies.""" - errors: dict[str, str] = {} - if user_input is not None: - data = { - CONF_TYPE: CONNECTION_SERIAL, - **user_input, - # Store the uppercase parity code the connection expects. - CONF_PARITY: user_input[CONF_PARITY].upper(), - } - # A serial link is identified by its device path alone, regardless of - # baud rate and other line settings. - self._async_abort_entries_match( - {CONF_TYPE: CONNECTION_SERIAL, CONF_DEVICE: data[CONF_DEVICE]} - ) - if not (errors := await self._async_validate(data)): - return self.async_create_entry(title=data[CONF_DEVICE], data=data) - return self.async_show_form( - step_id="serial", data_schema=STEP_SERIAL, errors=errors - ) - - async def _async_validate(self, data: dict[str, Any]) -> dict[str, str]: - """Validate by actually opening the connection; return form errors.""" - try: - connection = await _async_open(data) - except ModbusError: - if data[CONF_TYPE] == CONNECTION_SERIAL: - return {"base": "cannot_open_serial_port"} - return {"base": "cannot_connect"} - await connection.close() - return {} diff --git a/homeassistant/components/modbus_connection/const.py b/homeassistant/components/modbus_connection/const.py deleted file mode 100644 index 369ecc4c5a09..000000000000 --- a/homeassistant/components/modbus_connection/const.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Constants for the Modbus Connection integration.""" - -from typing import Final - -DOMAIN: Final = "modbus_connection" - -# Transport selection (stored under homeassistant.const.CONF_TYPE). -CONNECTION_TCP: Final = "tcp" -CONNECTION_SERIAL: Final = "serial" - -# Serial-only options. -CONF_BAUDRATE: Final = "baudrate" -CONF_BYTESIZE: Final = "bytesize" -CONF_PARITY: Final = "parity" -CONF_STOPBITS: Final = "stopbits" - -DEFAULT_PORT: Final = 502 -DEFAULT_BAUDRATE: Final = 9600 -DEFAULT_BYTESIZE: Final = 8 -DEFAULT_PARITY: Final = "n" -DEFAULT_STOPBITS: Final = 1 diff --git a/homeassistant/components/modbus_connection/exceptions.py b/homeassistant/components/modbus_connection/exceptions.py deleted file mode 100644 index 5c1ee68134b9..000000000000 --- a/homeassistant/components/modbus_connection/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Exceptions for the Modbus Connection integration.""" - -from modbus_connection import ModbusError - -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - - -class ConnectionNotReady(ConfigEntryNotReady, ModbusError): - """The shared Modbus connection is missing or not loaded. - - Raised by ``async_get_unit``. It is a ``ConfigEntryNotReady`` so a consumer - integration can let it propagate from its own ``async_setup_entry`` to get - Home Assistant's setup-retry behaviour, and a ``ModbusError`` so it is also - catchable with the library's error type. - """ - - def __init__(self, connection_entry_id: str) -> None: - """Initialize the error.""" - super().__init__( - translation_domain=DOMAIN, - translation_key="connection_not_ready", - ) - self.connection_entry_id = connection_entry_id diff --git a/homeassistant/components/modbus_connection/manifest.json b/homeassistant/components/modbus_connection/manifest.json deleted file mode 100644 index 156d5f3e45a8..000000000000 --- a/homeassistant/components/modbus_connection/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "modbus_connection", - "name": "Modbus Connection", - "codeowners": ["@home-assistant/core"], - "config_flow": true, - "dependencies": ["usb"], - "documentation": "https://www.home-assistant.io/integrations/modbus_connection", - "integration_type": "hub", - "iot_class": "local_polling", - "loggers": ["modbus_connection", "tmodbus"], - "quality_scale": "bronze", - "requirements": ["modbus-connection[tmodbus]==3.7.0"] -} diff --git a/homeassistant/components/modbus_connection/quality_scale.yaml b/homeassistant/components/modbus_connection/quality_scale.yaml deleted file mode 100644 index 6eb47cc6c8b6..000000000000 --- a/homeassistant/components/modbus_connection/quality_scale.yaml +++ /dev/null @@ -1,119 +0,0 @@ -rules: - # Bronze - action-setup: - status: exempt - comment: This integration does not register any service actions. - appropriate-polling: - status: exempt - comment: | - This integration does not poll. It owns a connection and hands out units; - consumer integrations poll through their own coordinators. - brands: done - common-modules: done - config-flow: done - config-flow-test-coverage: done - dependency-transparency: done - docs-actions: - status: exempt - comment: This integration does not register any service actions. - docs-conditions: - status: exempt - comment: This integration does not provide any conditions. - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - docs-triggers: - status: exempt - comment: This integration does not provide any triggers. - entity-event-setup: - status: exempt - comment: This integration provides no entities. - entity-unique-id: - status: exempt - comment: This integration provides no entities. - has-entity-name: - status: exempt - comment: This integration provides no entities. - runtime-data: done - test-before-configure: done - test-before-setup: done - unique-config-entry: done - # Silver - action-exceptions: - status: exempt - comment: This integration does not register any service actions. - config-entry-unloading: done - docs-configuration-parameters: done - docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: This integration provides no entities. - integration-owner: done - log-when-unavailable: - status: exempt - comment: | - This integration provides no entities; availability is surfaced to - consumers via on_connection_lost and failing reads. - parallel-updates: - status: exempt - comment: This integration provides no entity platforms. - reauthentication-flow: - status: exempt - comment: A Modbus link has no authentication. - test-coverage: done - # Gold - devices: - status: exempt - comment: This integration provides connections, not devices or entities. - diagnostics: todo - discovery: - status: exempt - comment: Modbus links are not discoverable. - discovery-update-info: - status: exempt - comment: Modbus links are not discoverable. - docs-data-update: - status: exempt - comment: This integration provides no entities to update. - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: - status: exempt - comment: This integration is a connection provider, not a device integration. - docs-supported-functions: - status: exempt - comment: This integration provides no entities. - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: - status: exempt - comment: This integration provides no devices. - entity-category: - status: exempt - comment: This integration provides no entities. - entity-device-class: - status: exempt - comment: This integration provides no entities. - entity-disabled-by-default: - status: exempt - comment: This integration provides no entities. - entity-translations: - status: exempt - comment: This integration provides no entities. - exception-translations: todo - icon-translations: - status: exempt - comment: This integration provides no entities. - reconfiguration-flow: todo - repair-issues: - status: exempt - comment: No repairable issues are raised. - stale-devices: - status: exempt - comment: This integration provides no devices. - # Platinum - async-dependency: done - inject-websession: - status: exempt - comment: This integration talks Modbus, not HTTP. - strict-typing: done diff --git a/homeassistant/components/modbus_connection/strings.json b/homeassistant/components/modbus_connection/strings.json deleted file mode 100644 index a71d59af83bf..000000000000 --- a/homeassistant/components/modbus_connection/strings.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_open_serial_port": "Failed to open the serial port" - }, - "step": { - "modbus_tcp": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" - }, - "data_description": { - "host": "The hostname or IP address of the Modbus gateway or device.", - "port": "The TCP port the Modbus gateway listens on (default 502)." - }, - "title": "Modbus TCP" - }, - "serial": { - "data": { - "baudrate": "Baud rate", - "bytesize": "Byte size", - "device": "[%key:common::config_flow::data::device%]", - "parity": "Parity", - "stopbits": "Stop bits" - }, - "data_description": { - "baudrate": "The serial baud rate the device communicates at.", - "bytesize": "The number of data bits.", - "device": "The serial port the Modbus device is connected to, e.g. /dev/ttyUSB0.", - "parity": "The serial parity.", - "stopbits": "The number of stop bits." - }, - "title": "Serial connection" - }, - "user": { - "description": "How is the Modbus network connected?", - "menu_options": { - "modbus_tcp": "Modbus TCP", - "serial": "Serial (including serial proxies and networked connections)" - } - } - } - }, - "exceptions": { - "connection_not_ready": { - "message": "Modbus connection not ready" - } - }, - "selector": { - "parity": { - "options": { - "e": "Even", - "n": "None", - "o": "Odd" - } - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index da9a9ef06b1b..2cfe8887bdc9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -478,7 +478,6 @@ FLOWS = { "mjpeg", "moat", "mobile_app", - "modbus_connection", "modem_callerid", "modern_forms", "moehlenhoff_alpha2", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b676f78a5c2a..eb8c5b96c390 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4416,12 +4416,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "modbus_connection": { - "name": "Modbus Connection", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "modem_callerid": { "name": "Phone Modem", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 2da3ccca92c7..6752fcf2621c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3587,16 +3587,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.modbus_connection.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.modem_callerid.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e7521e0cb174..19eddda8eddf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1600,9 +1600,6 @@ mitsubishi-comfort==0.3.2 # homeassistant.components.moat moat-ble==0.1.1 -# homeassistant.components.modbus_connection -modbus-connection[tmodbus]==3.7.0 - # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 diff --git a/tests/components/modbus_connection/__init__.py b/tests/components/modbus_connection/__init__.py deleted file mode 100644 index ecbad3432af6..000000000000 --- a/tests/components/modbus_connection/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Modbus Connection integration.""" diff --git a/tests/components/modbus_connection/conftest.py b/tests/components/modbus_connection/conftest.py deleted file mode 100644 index 379fcd664435..000000000000 --- a/tests/components/modbus_connection/conftest.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Common fixtures for the Modbus Connection tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -from modbus_connection.mock import MockModbusConnection -import pytest - -from homeassistant.components.modbus_connection.const import CONNECTION_TCP, DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Prevent the created entry from actually setting up during flow tests.""" - with patch( - "homeassistant.components.modbus_connection.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_connect( - mock_modbus_connection: MockModbusConnection, -) -> Generator[AsyncMock]: - """Patch the backend connect functions to return the mock connection.""" - connect = AsyncMock(return_value=mock_modbus_connection) - with ( - patch("homeassistant.components.modbus_connection.connect_tcp", connect), - patch("homeassistant.components.modbus_connection.connect_serial", connect), - ): - yield connect - - -@pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: - """Return a TCP connection config entry, already added to hass.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="1.2.3.4:502", - data={CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - ) - entry.add_to_hass(hass) - return entry - - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_connect: AsyncMock, -) -> MockConfigEntry: - """Set up the connection entry (loaded). - - Relies on ``mock_config_entry`` already being in hass. - """ - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry diff --git a/tests/components/modbus_connection/test_config_flow.py b/tests/components/modbus_connection/test_config_flow.py deleted file mode 100644 index cb5d4d199caf..000000000000 --- a/tests/components/modbus_connection/test_config_flow.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Tests for the Modbus Connection config flow.""" - -from typing import Any -from unittest.mock import AsyncMock - -from modbus_connection import ModbusConnectionError -from modbus_connection.mock import MockModbusConnection -import pytest - -from homeassistant.components.modbus_connection.const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DOMAIN, -) -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - -SERIAL_INPUT = { - CONF_DEVICE: "/dev/ttyUSB0", - CONF_BAUDRATE: 9600, - CONF_PARITY: "n", - CONF_STOPBITS: 1, - CONF_BYTESIZE: 8, -} - - -async def _start_menu(hass: HomeAssistant, step: str) -> str: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.MENU - assert set(result["menu_options"]) == {"modbus_tcp", "serial"} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": step} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == step - return result["flow_id"] - - -@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") -async def test_modbus_tcp_flow(hass: HomeAssistant) -> None: - """The Modbus TCP step opens the connection and creates an entry.""" - flow_id = await _start_menu(hass, "modbus_tcp") - result = await hass.config_entries.flow.async_configure( - flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_TYPE: CONNECTION_TCP, - CONF_HOST: "1.2.3.4", - CONF_PORT: 502, - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_modbus_tcp_cannot_connect_then_recovers( - hass: HomeAssistant, - mock_connect: AsyncMock, - mock_modbus_connection: MockModbusConnection, -) -> None: - """A failed probe shows an error; a later success creates the entry.""" - flow_id = await _start_menu(hass, "modbus_tcp") - mock_connect.side_effect = ModbusConnectionError("nope") - result = await hass.config_entries.flow.async_configure( - flow_id, {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - mock_connect.side_effect = None - mock_connect.return_value = mock_modbus_connection - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_PORT: 502} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - -@pytest.mark.usefixtures("mock_connect", "mock_setup_entry") -async def test_serial_flow(hass: HomeAssistant) -> None: - """The serial step opens the connection and creates a serial entry.""" - flow_id = await _start_menu(hass, "serial") - result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) - assert result["type"] is FlowResultType.CREATE_ENTRY - # Parity is stored uppercase (the code the connection expects). - assert result["data"] == { - CONF_TYPE: CONNECTION_SERIAL, - **SERIAL_INPUT, - CONF_PARITY: "N", - } - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_serial_cannot_open(hass: HomeAssistant, mock_connect: AsyncMock) -> None: - """A failed serial open shows the serial-specific error.""" - flow_id = await _start_menu(hass, "serial") - mock_connect.side_effect = ModbusConnectionError("nope") - result = await hass.config_entries.flow.async_configure(flow_id, SERIAL_INPUT) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_open_serial_port"} - - -@pytest.mark.parametrize( - ("step", "data", "user_input"), - [ - pytest.param( - "modbus_tcp", - {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - {CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - id="modbus_tcp", - ), - pytest.param( - "serial", - {CONF_TYPE: CONNECTION_SERIAL, **SERIAL_INPUT}, - SERIAL_INPUT, - id="serial", - ), - ], -) -async def test_duplicate_aborts( - hass: HomeAssistant, - step: str, - data: dict[str, Any], - user_input: dict[str, Any], -) -> None: - """Re-adding an already-configured link aborts before opening it. - - The dedupe runs before opening the connection, so no connect is needed. - """ - MockConfigEntry(domain=DOMAIN, data=data).add_to_hass(hass) - - flow_id = await _start_menu(hass, step) - result = await hass.config_entries.flow.async_configure(flow_id, user_input) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/modbus_connection/test_init.py b/tests/components/modbus_connection/test_init.py deleted file mode 100644 index 9b49de25ef39..000000000000 --- a/tests/components/modbus_connection/test_init.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for Modbus Connection setup, teardown and the async_get_unit accessor.""" - -from typing import Any -from unittest.mock import AsyncMock, patch - -from modbus_connection import ModbusConnectionError, ModbusError -from modbus_connection.mock import MockModbusConnection, MockModbusUnit -import pytest - -from homeassistant.components.modbus_connection import ( - ConnectionNotReady, - async_get_unit, -) -from homeassistant.components.modbus_connection.const import ( - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - CONNECTION_SERIAL, - CONNECTION_TCP, - DOMAIN, -) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_setup_and_unload( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_connection: MockModbusConnection, -) -> None: - """A connection entry loads, exposes runtime data, and closes on unload.""" - assert init_integration.state is ConfigEntryState.LOADED - assert init_integration.runtime_data is mock_modbus_connection - assert mock_modbus_connection.connected is True - - assert await hass.config_entries.async_unload(init_integration.entry_id) - await hass.async_block_till_done() - assert init_integration.state is ConfigEntryState.NOT_LOADED - assert mock_modbus_connection.connected is False - - -@pytest.mark.parametrize( - ("data", "error"), - [ - pytest.param( - {CONF_TYPE: CONNECTION_TCP, CONF_HOST: "1.2.3.4", CONF_PORT: 502}, - ModbusConnectionError("boom"), - id="tcp", - ), - pytest.param( - { - CONF_TYPE: CONNECTION_SERIAL, - CONF_DEVICE: "/dev/ttyUSB0", - CONF_BAUDRATE: 9600, - CONF_PARITY: "N", - CONF_STOPBITS: 1, - CONF_BYTESIZE: 8, - }, - ModbusError("port busy"), - id="serial", - ), - ], -) -async def test_setup_retry_when_connect_fails( - hass: HomeAssistant, - mock_connect: AsyncMock, - data: dict[str, Any], - error: ModbusError, -) -> None: - """A failed open raises ConfigEntryNotReady (setup retry). - - The serial case uses a generic ``ModbusError`` (not a ``ModbusConnectionError``) - to confirm setup retries on any library error, matching the config flow. - """ - entry = MockConfigEntry(domain=DOMAIN, data=data) - entry.add_to_hass(hass) - mock_connect.side_effect = error - - assert not await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_connection_lost_schedules_reload( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_connection: MockModbusConnection, -) -> None: - """Losing the connection schedules a reload of the entry.""" - with patch.object(hass.config_entries, "async_schedule_reload") as schedule_reload: - mock_modbus_connection.simulate_connection_lost() - await hass.async_block_till_done() - - schedule_reload.assert_called_once_with(init_integration.entry_id) - - -async def test_get_unit_returns_connection_unit( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_modbus_unit: MockModbusUnit, -) -> None: - """async_get_unit hands back the connection's own unit handle.""" - assert async_get_unit(hass, init_integration.entry_id, 1) is mock_modbus_unit - - -async def test_get_unit_not_ready_when_unloaded( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """A modbus_connection entry that is not loaded raises ConnectionNotReady.""" - # mock_config_entry is added to hass but never set up -> not LOADED. - with pytest.raises(ConnectionNotReady): - async_get_unit(hass, mock_config_entry.entry_id, 1) - - -async def test_get_unit_rejects_invalid_entry(hass: HomeAssistant) -> None: - """An unknown entry_id or a foreign-domain entry raises ValueError.""" - with pytest.raises(ValueError): - async_get_unit(hass, "does-not-exist", 1) - - other = MockConfigEntry(domain="sun", state=ConfigEntryState.LOADED) - other.add_to_hass(hass) - with pytest.raises(ValueError): - async_get_unit(hass, other.entry_id, 1) From ac6aedab34ca64835fbf70925e7a203fd4c9ca5e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jul 2026 16:55:02 +0200 Subject: [PATCH 654/707] Create individual devices for telegram_bot chats (#176606) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/telegram_bot/__init__.py | 65 ++++++ .../components/telegram_bot/config_flow.py | 2 +- .../components/telegram_bot/entity.py | 16 +- .../components/telegram_bot/notify.py | 10 +- tests/components/telegram_bot/test_init.py | 203 +++++++++++++++++- tests/components/telegram_bot/test_notify.py | 4 +- .../telegram_bot/test_telegram_bot.py | 28 +-- 7 files changed, 291 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 18c979b2f373..247c057d030a 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -3,6 +3,7 @@ import logging from typing import Protocol, cast +import telegram from telegram import Bot from telegram.constants import InputMediaType from telegram.error import InvalidToken, TelegramError @@ -33,6 +34,7 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, entity_registry as er, issue_registry as ir, ) @@ -104,6 +106,7 @@ from .const import ( CHAT_ACTION_UPLOAD_VIDEO_NOTE, CHAT_ACTION_UPLOAD_VOICE, CONF_API_ENDPOINT, + CONF_CHAT_ID, CONF_CONFIG_ENTRY_ID, DEFAULT_API_ENDPOINT, DOMAIN, @@ -705,6 +708,50 @@ async def async_migrate_entry( updated, ) + # version 1.2 -> 1.3: move each chat's notify entity onto its own per-chat device + # (linked to the bot device) and strip the chat subentries from the bot device, leaving + # it associated with only (entry, None). + if version == 1 and config_entry.minor_version < 3: + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + # Up to 1.2 the entry has a single device, the bot device, shared by every chat + devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + if devices: + bot_device = devices[0] + bot_id = next( + identifier + for domain, identifier in bot_device.identifiers + if domain == DOMAIN + ) + notify_entities = { + entity.config_subentry_id: entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + # The event entity (no subentry) stays on the shared bot device + if entity.config_subentry_id is not None + } + for subentry_id, subentry in config_entry.subentries.items(): + per_chat_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_id, + identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")}, + via_device=(DOMAIN, bot_id), + ) + if entity := notify_entities.get(subentry_id): + entity_registry.async_update_entity( + entity.entity_id, device_id=per_chat_device.id + ) + # Strip this chat's subentry from the bot device, leaving (entry, None) + device_registry.async_update_device( + bot_device.id, + remove_config_entry_id=config_entry.entry_id, + remove_config_subentry_id=subentry_id, + ) + hass.config_entries.async_update_entry(config_entry, minor_version=3) + return True @@ -906,6 +953,18 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]: return chat_ids +def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.DeviceInfo: + """Return device info for the shared bot device.""" + return dr.DeviceInfo( + name=config_entry.title, + entry_type=dr.DeviceEntryType.SERVICE, + manufacturer="Telegram", + model=config_entry.data[CONF_PLATFORM].capitalize(), + sw_version=telegram.__version__, + identifiers={(DOMAIN, f"{bot_id}")}, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool: """Create the Telegram bot from config entry.""" bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data) @@ -933,6 +992,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) ) entry.runtime_data = notify_service + # Create the bot device before the platforms are set up, so the per-chat devices can + # resolve it as their via_device no matter which platform is set up first + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **bot_device_info(entry, bot.id) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 6d5422b8368b..0aa84bc996c9 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -192,7 +192,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Telegram.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py index 95adc934781a..1b71426a89fe 100644 --- a/homeassistant/components/telegram_bot/entity.py +++ b/homeassistant/components/telegram_bot/entity.py @@ -1,13 +1,8 @@ """Base entity for Telegram bot integration.""" -import telegram - -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from . import TelegramBotConfigEntry -from .const import DOMAIN +from . import TelegramBotConfigEntry, bot_device_info class TelegramBotEntity(Entity): @@ -28,11 +23,4 @@ class TelegramBotEntity(Entity): self.service = config_entry.runtime_data self._attr_unique_id = f"{self.bot_id}_{entity_description.key}" - self._attr_device_info = DeviceInfo( - name=config_entry.title, - entry_type=DeviceEntryType.SERVICE, - manufacturer="Telegram", - model=config_entry.data[CONF_PLATFORM].capitalize(), - sw_version=telegram.__version__, - identifiers={(DOMAIN, f"{self.bot_id}")}, - ) + self._attr_device_info = bot_device_info(config_entry, self.bot_id) diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py index c49d106a84d2..e95cf2de681e 100644 --- a/homeassistant/components/telegram_bot/notify.py +++ b/homeassistant/components/telegram_bot/notify.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelegramBotConfigEntry -from .const import ATTR_TITLE, CONF_CHAT_ID +from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN from .entity import TelegramBotEntity @@ -33,6 +33,7 @@ async def async_setup_entry( class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): """Representation of a telegram bot notification entity.""" + _attr_name = None _attr_supported_features = NotifyEntityFeature.TITLE def __init__( @@ -45,7 +46,12 @@ class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): config_entry, NotifyEntityDescription(key=subentry.data[CONF_CHAT_ID]) ) self.chat_id = subentry.data[CONF_CHAT_ID] - self._attr_name = subentry.title + # Each chat gets its own device (keyed per chat) linked to the shared bot device. + device_info = self._attr_device_info + assert device_info is not None + device_info["identifiers"] = {(DOMAIN, f"{self.bot_id}_{self.chat_id}")} + device_info["name"] = subentry.title + device_info["via_device"] = (DOMAIN, f"{self.bot_id}") @override async def async_send_message(self, message: str, title: str | None = None) -> None: diff --git a/tests/components/telegram_bot/test_init.py b/tests/components/telegram_bot/test_init.py index f5d54e19b241..7c3bfb6cacf0 100644 --- a/tests/components/telegram_bot/test_init.py +++ b/tests/components/telegram_bot/test_init.py @@ -1,16 +1,21 @@ """Init tests for the Telegram Bot integration.""" +import pytest + from homeassistant.components.telegram_bot.const import ( ATTR_PARSER, + CONF_ALLOWED_CHAT_IDS, CONF_API_ENDPOINT, + CONF_CHAT_ID, DEFAULT_API_ENDPOINT, DOMAIN, PARSER_MD, PLATFORM_BROADCAST, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_PLATFORM from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -19,7 +24,7 @@ async def test_migration_error( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from unsupported version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -43,7 +48,7 @@ async def test_migrate_entry_from_1_1( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from 1.1, chaining through to the latest version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -61,9 +66,199 @@ async def test_migrate_entry_from_1_1( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.version == 1 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, } + + +@pytest.mark.parametrize( + "chats_without_notify_entity", + [ + pytest.param((), id="notify entities intact"), + pytest.param((654321,), id="notify entity deleted"), + ], +) +async def test_migrate_entry_to_per_chat_devices( + hass: HomeAssistant, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + chats_without_notify_entity: tuple[int, ...], +) -> None: + """Test migrating a shared bot device to per-chat devices.""" + bot_id = 123456 # test_user id from mock_external_calls + chat_ids = (123456, 654321) + config_entry = MockConfigEntry( + unique_id="mock api key", + domain=DOMAIN, + minor_version=2, + data={ + CONF_PLATFORM: PLATFORM_BROADCAST, + CONF_API_KEY: "mock api key", + CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, + }, + options={ATTR_PARSER: PARSER_MD}, + subentries_data=[ + ConfigSubentryData( + unique_id="123456", + data={CONF_CHAT_ID: 123456}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 1", + ), + ConfigSubentryData( + unique_id="654321", + data={CONF_CHAT_ID: 654321}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 2", + ), + ], + ) + config_entry.add_to_hass(hass) + subentry_ids = list(config_entry.subentries) + + # Pre-migration state: one shared bot device associated with the config entry (None) + # and every chat subentry, holding the event entity and every chat's notify entity. + bot_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, str(bot_id))}, + ) + for subentry_id in subentry_ids: + bot_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_id, + identifiers={(DOMAIN, str(bot_id))}, + ) + assert bot_device.config_entries_subentries == { + config_entry.entry_id: {None, *subentry_ids} + } + + event_entity = entity_registry.async_get_or_create( + "event", + DOMAIN, + f"{bot_id}_update_event", + config_entry=config_entry, + device_id=bot_device.id, + ) + notify_entities = { + chat_id: entity_registry.async_get_or_create( + "notify", + DOMAIN, + f"{bot_id}_{chat_id}", + config_entry=config_entry, + config_subentry_id=subentry_id, + device_id=bot_device.id, + ) + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True) + if chat_id not in chats_without_notify_entity + } + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.minor_version == 3 + + # Each chat has its own device, owned by that chat's subentry and linked to the bot + # device. + chat_devices = { + chat_id: device_registry.async_get_device( + identifiers={(DOMAIN, f"{bot_id}_{chat_id}")} + ) + for chat_id in chat_ids + } + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): + chat_device = chat_devices[chat_id] + assert chat_device is not None + assert chat_device.config_entries_subentries == { + config_entry.entry_id: {subentry_id} + } + assert chat_device.via_device_id == bot_device.id + + # Every notify entity that survived is moved onto its chat's device + for chat_id, notify_entity in notify_entities.items(): + assert ( + entity_registry.async_get(notify_entity.entity_id).device_id + == chat_devices[chat_id].id + ) + + # The bot device ends up associated with only (entry, None), keeping the event entity + bot_device = device_registry.async_get(bot_device.id) + assert bot_device is not None + assert bot_device.config_entries_subentries == {config_entry.entry_id: {None}} + assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id + + +async def test_per_chat_devices( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Each chat gets its own device linked to the config-entry-level bot device.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + entry_id = mock_broadcast_config_entry.entry_id + + # The bot device belongs to the config entry (no subentry) and holds the event entity + bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) + assert bot_device is not None + assert bot_device.config_entries_subentries == {entry_id: {None}} + assert bot_device.name == "Mock Title" + + for chat_id, chat_name in ((123456, "mock chat 1"), (654321, "mock chat 2")): + subentry_id = next( + sid + for sid, subentry in mock_broadcast_config_entry.subentries.items() + if subentry.data[CONF_CHAT_ID] == chat_id + ) + chat_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"123456_{chat_id}")} + ) + assert chat_device is not None + assert chat_device.config_entries_subentries == {entry_id: {subentry_id}} + assert chat_device.via_device_id == bot_device.id + # The device is named after the chat, and its notify entity takes the device name + assert chat_device.name == chat_name + notify_entity_id = entity_registry.async_get_entity_id( + "notify", DOMAIN, f"123456_{chat_id}" + ) + assert notify_entity_id is not None + assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id + assert hass.states.get(notify_entity_id).name == chat_name + + +async def test_remove_chat_subentry_removes_per_chat_device( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Removing a chat subentry removes just its per-chat device and notify entity.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + subentry_id = next( + sid + for sid, subentry in mock_broadcast_config_entry.subentries.items() + if subentry.data[CONF_CHAT_ID] == 123456 + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + + hass.config_entries.async_remove_subentry(mock_broadcast_config_entry, subentry_id) + await hass.async_block_till_done() + + # The removed chat's device and notify entity are gone; the other chat and the bot + # device remain + assert not device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert not entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_654321")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) diff --git a/tests/components/telegram_bot/test_notify.py b/tests/components/telegram_bot/test_notify.py index 2305114127d9..ffa4daebff12 100644 --- a/tests/components/telegram_bot/test_notify.py +++ b/tests/components/telegram_bot/test_notify.py @@ -43,7 +43,7 @@ async def test_send_message( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", ATTR_MESSAGE: "mock message", ATTR_TITLE: "mock title", }, @@ -64,7 +64,7 @@ async def test_send_message( message_thread_id=None, ) - state = hass.states.get("notify.mock_title_mock_chat") + state = hass.states.get("notify.mock_chat") assert state assert state.state == "2025-01-09T12:00:00+00:00" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index a4592826d752..3e7fd2848508 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -224,7 +224,7 @@ async def test_send_message( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -322,7 +322,7 @@ async def test_send_message_with_inline_keyboard( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -368,9 +368,9 @@ async def test_send_sticker_partial_error( assert err.value.translation_key == "multiple_errors" assert err.value.translation_placeholders == { "errors": ( - "`entity_id` notify.mock_title_mock_chat_1:" + "`entity_id` notify.mock_chat_1:" " mock network error\n" - "`entity_id` notify.mock_title_mock_chat_2:" + "`entity_id` notify.mock_chat_2:" " mock network error" ) } @@ -588,7 +588,7 @@ async def test_send_file(hass: HomeAssistant, webhook_bot, service: str) -> None { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -1076,7 +1076,7 @@ async def test_send_message_with_config_entry( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1187,7 +1187,7 @@ async def test_delete_message( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1616,7 +1616,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1648,7 +1648,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1837,7 +1837,7 @@ async def test_send_message_multi_target( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1857,7 +1857,7 @@ async def test_notify_entity_send_message( response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, - {ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", ATTR_MESSAGE: "test_message"}, + {ATTR_ENTITY_ID: "notify.mock_chat_2", ATTR_MESSAGE: "test_message"}, blocking=True, return_response=True, ) @@ -1867,7 +1867,7 @@ async def test_notify_entity_send_message( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1921,7 +1921,7 @@ async def test_migrate_chat_id( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -2616,7 +2616,7 @@ async def test_send_media_group( "chats": [ { ATTR_CHAT_ID: 123456, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", ATTR_MESSAGE_ID: [12345, 12346, 12347, 12348], } ] From ec366c5b7f02e884d7f0daf7c8ec25fc62377406 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 16 Jul 2026 17:03:37 +0200 Subject: [PATCH 655/707] Rename Modbus integration back to Modbus (#176619) Co-authored-by: Claude Fable 5 --- homeassistant/components/modbus/manifest.json | 2 +- homeassistant/components/modbus/strings.json | 3 +-- homeassistant/generated/integrations.json | 2 +- script/hassfest/translations.py | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7709841a3b96..30945c8a13df 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,6 +1,6 @@ { "domain": "modbus", - "name": "Manual Modbus", + "name": "Modbus", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 08d29cc9aafb..d0d78d726e05 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -90,6 +90,5 @@ }, "name": "Write register" } - }, - "title": "Manual Modbus" + } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index eb8c5b96c390..1126febd6237 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4412,6 +4412,7 @@ "iot_class": "local_polling" }, "modbus": { + "name": "Modbus", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" @@ -8548,7 +8549,6 @@ "local_todo", "min_max", "mobile_app", - "modbus", "moehlenhoff_alpha2", "mold_indicator", "moon", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 7369ed7b7310..f9f2d1df308d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -45,7 +45,6 @@ ALLOW_NAME_TRANSLATION = { "local_calendar", "local_ip", "local_todo", - "modbus", "nmap_tracker", "remote_calendar", "rpi_power", From 2d0ac67a9ab11e4efb06e1d80caf4456372d2114 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:04:08 +0100 Subject: [PATCH 656/707] Update uv to 0.11.28 (#176585) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd2b9ffc2b6b..9d1303105b2c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.26 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 7d545f071e6d..76c8700a0ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.26", + "uv==0.11.28", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", diff --git a/requirements.txt b/requirements.txt index 79d031f9c272..ae925cb00902 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.26 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 From 151e00727cce718b5ea78c87b55da331709db1a7 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:40:23 +0300 Subject: [PATCH 657/707] Bump victron-mqtt to 2026.7.4 (#176614) --- .../components/victron_gx/manifest.json | 2 +- .../components/victron_gx/strings.json | 17 +++++++++++++++++ requirements_all.txt | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 61908b84b513..a14b7b43f7ff 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["victron-mqtt==2026.7.0"], + "requirements": ["victron-mqtt==2026.7.4"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 2f88854cfd89..dc4cd29e0326 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -403,6 +403,13 @@ "passthrough": "[%key:component::victron_gx::common::passthrough%]" } }, + "battery_bms_mode": { + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "standby": "[%key:common::state::standby%]" + } + }, "evcharger_mode": { "name": "[%key:common::config_flow::data::mode%]", "state": { @@ -1899,6 +1906,16 @@ "system_heartbeat": { "name": "GX system heartbeat" }, + "system_pv_on_grid_current_phase": { + "name": "PV on grid current {phase}" + }, + "system_pv_on_grid_phases": { + "name": "PV on grid phases", + "unit_of_measurement": "phases" + }, + "system_pv_on_grid_power_phase": { + "name": "PV on grid power {phase}" + }, "system_pv_on_output_current_phase": { "name": "PV on output current {phase}" }, diff --git a/requirements_all.txt b/requirements_all.txt index 19eddda8eddf..9f632fe215e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3311,7 +3311,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx -victron-mqtt==2026.7.0 +victron-mqtt==2026.7.4 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.12 From 0d9a5c381b8dec6096875dccfbccde7685e72a33 Mon Sep 17 00:00:00 2001 From: David Wu <133224895+David-Wu1119@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:58:58 +0800 Subject: [PATCH 658/707] Raise HomeAssistantError on network errors in lg_thinq service calls (#176501) Co-authored-by: David Wu --- homeassistant/components/lg_thinq/entity.py | 10 +++++++- .../components/lg_thinq/strings.json | 3 +++ tests/components/lg_thinq/test_climate.py | 23 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 0e614a8b363b..5cce4f3857a3 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -4,12 +4,13 @@ from collections.abc import Callable, Coroutine import logging from typing import Any, override +from aiohttp import ClientError from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState from homeassistant.core import callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,3 +113,10 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): if on_fail_method: on_fail_method() raise ServiceValidationError(exc) from exc + except (TimeoutError, ClientError) as exc: + if on_fail_method: + on_fail_method() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from exc diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 4ded58f514fd..dd739a8ca21e 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -1163,6 +1163,9 @@ } }, "exceptions": { + "connection_error": { + "message": "Failed to connect to the LG ThinQ cloud. Please try again later." + }, "failed_to_connect_mqtt": { "message": "Failed to connect MQTT: {error}" } diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index e9bfe2056645..003a1240299c 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -86,3 +87,25 @@ async def test_fan_mode_service_calls( coordinator.api.async_set_fan_mode.assert_awaited_once_with( "climate_air_conditioner", expected_value ) + + +@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +@pytest.mark.usefixtures("devices") +async def test_service_call_connection_error_raises_home_assistant_error( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a network error during a service call raises HomeAssistantError.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + mock_thinq_api.async_post_device_control.side_effect = TimeoutError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.test_air_conditioner", "fan_mode": "low"}, + blocking=True, + ) From 50d87b0924301c7a42e3a36f3dce5d9b493a6efc Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 16 Jul 2026 12:45:42 -0600 Subject: [PATCH 659/707] Bump intellifire4py to 4.5.0 (#175787) --- homeassistant/components/intellifire/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 4feef90a7f72..ffe8bed9117f 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==4.4.0"] + "requirements": ["intellifire4py==4.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9f632fe215e2..e0f940c1b7a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1380,7 +1380,7 @@ inkbird-ble==1.4.4 insteon-frontend-home-assistant==0.6.2 # homeassistant.components.intellifire -intellifire4py==4.4.0 +intellifire4py==4.5.0 # homeassistant.components.iometer iometer==1.0.2 From 4917ba75ceeb34c9892821a8681ddcfa28998ad6 Mon Sep 17 00:00:00 2001 From: bdlcalvin <149634165+bdlcalvin@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:00:56 -0400 Subject: [PATCH 660/707] Add oven target temperature number to Whirlpool (#173411) --- .../components/whirlpool/__init__.py | 1 + homeassistant/components/whirlpool/number.py | 79 ++++++++ homeassistant/components/whirlpool/sensor.py | 14 +- .../components/whirlpool/strings.json | 19 ++ .../whirlpool/snapshots/test_number.ambr | 184 ++++++++++++++++++ .../whirlpool/snapshots/test_sensor.ambr | 174 ----------------- tests/components/whirlpool/test_number.py | 177 +++++++++++++++++ tests/components/whirlpool/test_sensor.py | 112 +++++++++++ 8 files changed, 581 insertions(+), 179 deletions(-) create mode 100644 homeassistant/components/whirlpool/number.py create mode 100644 tests/components/whirlpool/snapshots/test_number.ambr create mode 100644 tests/components/whirlpool/test_number.py diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 4f74c34e7a50..2724a87d3079 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -22,6 +22,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CLIMATE, Platform.LIGHT, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/whirlpool/number.py b/homeassistant/components/whirlpool/number.py new file mode 100644 index 000000000000..a76c44014504 --- /dev/null +++ b/homeassistant/components/whirlpool/number.py @@ -0,0 +1,79 @@ +"""Number platform for the Whirlpool Appliances integration.""" + +from typing import override + +from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .const import DOMAIN +from .entity import WhirlpoolOvenEntity + +PARALLEL_UPDATES = 1 + +# Oven target temperatures are handled in Celsius. The appliance accepts +# tenth-of-a-degree values, so a 1-degree step gives fine manual control while +# automations can still set any value Home Assistant passes through. +OVEN_MIN_TEMP = 30 +OVEN_MAX_TEMP = 290 +OVEN_TEMP_STEP = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the number platform.""" + appliances_manager = config_entry.runtime_data + async_add_entities( + WhirlpoolOvenTargetTemperature(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + ) + + +class WhirlpoolOvenTargetTemperature(WhirlpoolOvenEntity, NumberEntity): + """Settable target temperature for an oven cavity.""" + + _attr_device_class = NumberDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_native_min_value = OVEN_MIN_TEMP + _attr_native_max_value = OVEN_MAX_TEMP + _attr_native_step = OVEN_TEMP_STEP + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven target temperature number.""" + super().__init__( + appliance, cavity, "oven_target_temperature", "-target_temperature" + ) + + @override + @property + def native_value(self) -> float | None: + """Return the current target temperature.""" + return self._appliance.get_target_temp(self.cavity) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set a new target temperature, keeping the current cook mode.""" + mode = self._appliance.get_cook_mode(self.cavity) + if mode is None or mode == CookMode.Standby: + mode = CookMode.Bake + try: + WhirlpoolOvenTargetTemperature._check_service_request( + await self._appliance.set_cook( + target_temp=value, mode=mode, cavity=self.cavity + ) + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index c2b31c641486..14c695db99fb 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -258,6 +258,9 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = ( ), ) +# Sensors replaced by more capable entities (select and number respectively). +DEPRECATED_OVEN_SENSOR_KEYS = ("oven_cook_mode", "oven_target_temperature") + def _build_oven_cavity_sensors( hass: HomeAssistant, @@ -269,14 +272,15 @@ def _build_oven_cavity_sensors( suffix = WhirlpoolOvenEntity.cavity_suffix(oven, cavity) sensors: list[SensorEntity] = [] for description in OVEN_CAVITY_SENSORS: - # The oven cook mode sensor has been replaced by a select entity. - if description.key == "oven_cook_mode" and not deprecate_entity( + # The oven cook mode and target temperature sensors have been replaced + # by select and number entities respectively. + if description.key in DEPRECATED_OVEN_SENSOR_KEYS and not deprecate_entity( hass, entity_registry, platform_domain=Platform.SENSOR, - entity_unique_id=f"{oven.said}-oven_cook_mode{suffix}", - issue_id=f"deprecated_oven_cook_mode_{oven.said}{suffix}", - translation_key="deprecated_oven_cook_mode", + entity_unique_id=f"{oven.said}-{description.key}{suffix}", + issue_id=f"deprecated_{description.key}_{oven.said}{suffix}", + translation_key=f"deprecated_{description.key}", ): continue sensors.append(WhirlpoolOvenCavitySensor(oven, cavity, description)) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index d08591304340..75a70e8e89b4 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -68,6 +68,17 @@ "name": "Upper oven light" } }, + "number": { + "oven_target_temperature": { + "name": "Target temperature" + }, + "oven_target_temperature_lower": { + "name": "Lower oven target temperature" + }, + "oven_target_temperature_upper": { + "name": "Upper oven target temperature" + } + }, "select": { "oven_cook_mode": { "name": "Cook mode", @@ -298,6 +309,14 @@ "deprecated_oven_cook_mode_scripts": { "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new select entity, then disable `{entity_id}` to have it removed.", "title": "[%key:component::whirlpool::issues::deprecated_oven_cook_mode::title%]" + }, + "deprecated_oven_target_temperature": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nUpdate any dashboards, templates, automations or scripts to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "The Whirlpool oven target temperature sensor is deprecated" + }, + "deprecated_oven_target_temperature_scripts": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::whirlpool::issues::deprecated_oven_target_temperature::title%]" } } } diff --git a/tests/components/whirlpool/snapshots/test_number.ambr b/tests/components/whirlpool/snapshots/test_number.ambr new file mode 100644 index 000000000000..841aa241e2b1 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_number.ambr @@ -0,0 +1,184 @@ +# serializer version: 1 +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lower oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_lower', + 'unique_id': 'said_oven_dual-target_temperature_lower', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Lower oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upper oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_upper', + 'unique_id': 'said_oven_dual-target_temperature_upper', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Upper oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature', + 'unique_id': 'said_oven_single-target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Single cavity oven Target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index d86b359ed3c8..ad2625d64019 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -269,64 +269,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Lower oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lower oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_lower', - 'unique_id': 'said_oven_dual-oven_target_temperature_lower', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Lower oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_upper_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -447,64 +389,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Upper oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Upper oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_upper', - 'unique_id': 'said_oven_dual-oven_target_temperature_upper', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Upper oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.single_cavity_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -625,64 +509,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.single_cavity_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.single_cavity_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature', - 'unique_id': 'said_oven_single-oven_target_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Single cavity oven Target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.single_cavity_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.washer_detergent_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/whirlpool/test_number.py b/tests/components/whirlpool/test_number.py new file mode 100644 index 000000000000..b2db959dfc34 --- /dev/null +++ b/tests/components/whirlpool/test_number.py @@ -0,0 +1,177 @@ +"""Test the Whirlpool number platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import whirlpool + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +@pytest.fixture( + params=[ + ( + "number.single_cavity_oven_target_temperature", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_upper_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_lower_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_number_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven target-temperature number entities.""" + return request.param + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +async def test_target_temperature_value( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test reading and updating the target temperature.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + assert hass.states.get(entity_id).state == "200" + + mock.get_target_temp.return_value = 220 + await trigger_attr_callback(hass, mock) + assert hass.states.get(entity_id).state == "220" + + +async def test_set_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test setting the target temperature issues a cook command.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_fractional_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a fractional target temperature is passed through without truncation.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220.5}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220.5, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_target_temperature_failure( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a failed request raises HomeAssistantError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.return_value = False + await init_integration(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +async def test_set_target_temperature_value_error( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a ValueError while setting the temperature raises ServiceValidationError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.side_effect = ValueError + await init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +@pytest.mark.parametrize("current_mode", [whirlpool.oven.CookMode.Standby, None]) +async def test_set_target_temperature_from_idle( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + current_mode: whirlpool.oven.CookMode | None, +) -> None: + """Test that setting the temperature with no active cook defaults to Bake.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = current_mode + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Bake, cavity=cavity + ) diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index cda4509462b8..38cbb4eea01d 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -473,3 +473,115 @@ async def test_oven_cook_mode_sensor_kept_when_used_by_automation( issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) assert issue is not None assert issue.translation_key == "deprecated_oven_cook_mode_scripts" + + +# The oven target temperature sensor has been replaced by a number entity. +DEPRECATED_TARGET_TEMP_UNIQUE_ID = "said_oven_single-oven_target_temperature" +DEPRECATED_TARGET_TEMP_ISSUE_ID = "deprecated_oven_target_temperature_said_oven_single" + + +async def test_oven_target_temperature_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated target temperature sensor is not created on a fresh install.""" + await init_integration(hass) + + assert hass.states.get("sensor.single_cavity_oven_target_temperature") is None + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an existing target temperature sensor is kept and raises a repair issue.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + ) + + await init_integration(hass) + + state = hass.states.get("sensor.single_cavity_oven_target_temperature") + assert state is not None + assert state.state == "200" + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) in issue_registry.issues + + +async def test_oven_target_temperature_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled deprecated target temperature sensor is removed.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await init_integration(hass) + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled target temperature sensor used by an automation is kept.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "trigger": { + "platform": "state", + "entity_id": "sensor.single_cavity_oven_target_temperature", + }, + "action": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await init_integration(hass) + + # The sensor is still referenced by an automation, so it is kept and the + # repair issue switches to the variant that lists the usage. + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) + assert issue is not None + assert issue.translation_key == "deprecated_oven_target_temperature_scripts" From 1d885bd073d9c8037c9c363204b1636662121bb6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jul 2026 22:10:10 +0200 Subject: [PATCH 661/707] Restrict device registry entries to a single config entry and subentry (#175785) --- .../components/ambient_station/__init__.py | 2 +- .../components/androidtv/diagnostics.py | 20 +- .../components/asuswrt/diagnostics.py | 20 +- .../components/device_automation/helpers.py | 37 + .../components/diagnostics/__init__.py | 9 +- homeassistant/components/diagnostics/util.py | 28 + .../dwd_weather_warnings/__init__.py | 2 +- .../components/enphase_envoy/diagnostics.py | 12 +- .../components/hassio/diagnostics.py | 9 +- .../hunterdouglas_powerview/diagnostics.py | 10 +- homeassistant/components/nut/diagnostics.py | 17 +- .../components/samsungtv/__init__.py | 2 +- .../components/telegram_bot/__init__.py | 18 +- .../components/version/diagnostics.py | 9 +- homeassistant/components/withings/sensor.py | 41 +- homeassistant/config_entries.py | 12 +- homeassistant/helpers/device_registry.py | 1507 ++++- homeassistant/helpers/entity_registry.py | 139 +- homeassistant/helpers/target.py | 14 +- homeassistant/scripts/auth.py | 4 + homeassistant/scripts/check_config.py | 2 + tests/auth/permissions/test_entities.py | 9 +- tests/common.py | 5 +- .../components/alexa_devices/test_services.py | 44 +- tests/components/anthropic/test_init.py | 2 +- tests/components/calendar/test_trigger.py | 8 +- tests/components/common.py | 7 +- .../components/config/test_device_registry.py | 112 +- tests/components/derivative/test_init.py | 35 +- .../components/device_automation/test_init.py | 146 +- tests/components/diagnostics/test_util.py | 34 + .../snapshots/test_diagnostics.ambr | 187 +- .../components/generic_hygrostat/test_init.py | 32 +- .../generic_thermostat/test_init.py | 32 +- .../test_init.py | 4 +- .../heos/snapshots/test_diagnostics.ambr | 1 + tests/components/history_stats/test_init.py | 33 +- tests/components/honeywell/test_init.py | 5 +- tests/components/integration/test_init.py | 35 +- tests/components/mold_indicator/test_init.py | 39 +- tests/components/mqtt/test_discovery.py | 67 +- tests/components/mqtt/test_tag.py | 55 +- tests/components/ollama/test_init.py | 2 +- .../openai_conversation/test_init.py | 2 +- tests/components/shelly/test_services.py | 25 - .../components/snooz/snapshots/test_init.ambr | 2 +- tests/components/statistics/test_init.py | 35 +- tests/components/switch_as_x/test_init.py | 32 +- tests/components/tasmota/test_discovery.py | 71 +- tests/components/telegram_bot/test_init.py | 69 +- tests/components/template/test_init.py | 13 +- tests/components/threshold/test_init.py | 35 +- tests/components/todo/test_trigger.py | 8 +- tests/components/trend/test_init.py | 35 +- tests/components/utility_meter/test_init.py | 35 +- tests/components/waqi/test_init.py | 4 + .../components/websocket_api/test_commands.py | 23 +- tests/components/withings/test_sensor.py | 55 + tests/components/wolflink/test_init.py | 3 +- tests/helpers/test_device_registry.py | 5118 +++++++++++------ tests/helpers/test_entity_registry.py | 650 ++- tests/helpers/test_helper_integration.py | 125 +- tests/helpers/test_service.py | 37 +- tests/helpers/test_target.py | 121 +- tests/syrupy.py | 43 +- tests/test_config_entries.py | 51 + 66 files changed, 6334 insertions(+), 3066 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 953743c66a6a..aa68ddbf5244 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(entry.entry_id) + dev_reg.async_clear_config_entry(entry.entry_id, entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(entry.entry_id) diff --git a/homeassistant/components/androidtv/diagnostics.py b/homeassistant/components/androidtv/diagnostics.py index 47cf6aa5ea88..e7f2cdb540c8 100644 --- a/homeassistant/components/androidtv/diagnostics.py +++ b/homeassistant/components/androidtv/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 7aa6d4d8a7ac..175c35c8297f 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, @@ -39,7 +41,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, "tracked_devices": [], } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index b2fa4bbb06f0..f7c5bfc32b5c 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -43,6 +43,32 @@ ENTITY_PLATFORMS = { } +def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str: + """Resolve a device automation device id, following a composite device id. + + A device automation created when a device could be connected to more than one + config entry stores the id of the (now removed) composite device. When the + automation's domain owns one of the split devices' config entries, resolve to that + device - an integration may look the device up in its own registry, which only + knows the current device id, not the removed composite id. + """ + device_registry = dr.async_get(hass) + if device_id in device_registry.devices: + return device_id + if not ( + split_devices := device_registry.async_get_devices_for_composite_device_id( + device_id + ) + ): + return device_id + # Resolve to the device owned by a config entry of the automation's domain + for split_device in split_devices: + entry = hass.config_entries.async_get_entry(split_device.config_entry_id) + if entry is not None and entry.domain == domain: + return split_device.id + return device_id + + async def async_validate_device_automation_config( hass: HomeAssistant, config: ConfigType, @@ -51,6 +77,17 @@ async def async_validate_device_automation_config( ) -> ConfigType: """Validate config.""" validated_config: ConfigType = automation_schema(config) + + # A device automation may reference a pre-migration composite device id; resolve it + # to the split device for its domain so the device and its entities exist and the + # integration platform (validation and attach/call) receives a live device id + resolved_device_id = _resolve_device_id( + hass, validated_config[CONF_DEVICE_ID], validated_config[CONF_DOMAIN] + ) + if resolved_device_id != validated_config[CONF_DEVICE_ID]: + config = {**config, CONF_DEVICE_ID: resolved_device_id} + validated_config = {**validated_config, CONF_DEVICE_ID: resolved_device_id} + platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 9d4b53093055..bca2cc73fd9e 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -36,9 +36,14 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType -from .util import async_redact_data, entity_entry_as_dict +from .util import async_redact_data, device_entry_as_dict, entity_entry_as_dict -__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"] +__all__ = [ + "REDACTED", + "async_redact_data", + "device_entry_as_dict", + "entity_entry_as_dict", +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 5dd6085e2df0..9326961c5d8c 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -6,6 +6,7 @@ from typing import Any, cast, overload import attr from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry from .const import REDACTED @@ -45,6 +46,33 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T: return cast(_T, redacted) +# DeviceEntry attributes that are internal bookkeeping and must not be exposed in +# diagnostics. Underscore attributes (_cache, _suggested_area, and the transient +# _pending_move / _composite_subentries) are excluded separately by _device_entry_filter. +# The composite-device migration attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + + +def _device_entry_filter(a: attr.Attribute, _: Any) -> bool: + return ( + not a.name.startswith("_") and a.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ) + + +@callback +def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]: + """Convert a device registry entry to a dict for diagnostics. + + This excludes internal fields that should not be exposed in diagnostics. + """ + return attr.asdict(entry, filter=_device_entry_filter) + + def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool: return a.name not in ( "_cache", diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 7945f39aeb29..67818456dbe3 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry( """Set up a config entry.""" device_registry = dr.async_get(hass) if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): - device_registry.async_clear_config_entry(entry.entry_id) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) coordinator = DwdWeatherWarningsCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 77d7c2a4dc97..7806ec781a23 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -5,11 +5,14 @@ from datetime import datetime from typing import TYPE_CHECKING, Any from aiohttp import ClientResponse -from attr import asdict from pyenphase.envoy import Envoy from pyenphase.exceptions import EnvoyError -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -119,10 +122,7 @@ async def async_get_config_entry_diagnostics( state_dict.pop("context", None) entity_dict = entity_entry_as_dict(entity) entities.append({"entity": entity_dict, "state": state_dict}) - device_dict = asdict(device) - device_dict.pop("_cache", None) - # This can be removed when suggested_area is removed from DeviceEntry - device_dict.pop("_suggested_area") + device_dict = device_entry_as_dict(device) device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index a3166d15888d..dc45e57ea2fb 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -53,7 +54,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity_entry), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "coordinator_data": coordinator.data.to_dict(), diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index eb90737faba3..89a04a4b143d 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -3,9 +3,11 @@ from dataclasses import asdict from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -75,7 +77,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, # Gather information how this device is represented in Home Assistant entity_registry = er.async_get(hass) - data = async_redact_data(attr.asdict(device), REDACT_CONFIG) + data = async_redact_data(device_entry_as_dict(device), REDACT_CONFIG) data["entities"] = [] entities: list[dict[str, Any]] = data["entities"] diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 1bda5ab4e4d5..06b965ae3cb6 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -41,7 +43,7 @@ async def async_get_config_entry_diagnostics( assert hass_device is not None data["device"] = { - **attr.asdict(hass_device), + **device_entry_as_dict(hass_device), "entities": {}, } @@ -61,10 +63,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), + **entity_dict, "state": state_dict, } diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 449c0722bde1..c07ca2fb8873 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -244,7 +244,7 @@ async def async_migrate_entry( # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(config_entry.entry_id) + dev_reg.async_clear_config_entry(config_entry.entry_id, config_entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(config_entry.entry_id) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 247c057d030a..844aed33075a 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -708,13 +708,11 @@ async def async_migrate_entry( updated, ) - # version 1.2 -> 1.3: move each chat's notify entity onto its own per-chat device - # (linked to the bot device) and strip the chat subentries from the bot device, leaving - # it associated with only (entry, None). + # version 1.2 -> 1.3: give each chat its own device, linked to the shared bot device, + # and make sure the bot device is tied to (entry, None). if version == 1 and config_entry.minor_version < 3: device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - # Up to 1.2 the entry has a single device, the bot device, shared by every chat devices = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) @@ -738,18 +736,16 @@ async def async_migrate_entry( config_entry_id=config_entry.entry_id, config_subentry_id=subentry_id, identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")}, - via_device=(DOMAIN, bot_id), + via_device_id=bot_device.id, ) if entity := notify_entities.get(subentry_id): entity_registry.async_update_entity( entity.entity_id, device_id=per_chat_device.id ) - # Strip this chat's subentry from the bot device, leaving (entry, None) - device_registry.async_update_device( - bot_device.id, - remove_config_entry_id=config_entry.entry_id, - remove_config_subentry_id=subentry_id, - ) + # Hand the bot device back to (entry, None), keeping the event entity + device_registry.async_update_device( + bot_device.id, new_config_subentry_id=None + ) hass.config_entries.async_update_entry(config_entry, minor_version=3) return True diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index b8f5a1195404..681eedfef4c9 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -45,7 +46,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "entry": config_entry.as_dict(), diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 520c89e7f399..73b56a8dae35 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -850,17 +850,22 @@ async def async_setup_entry( if new_devices: device_registry = dr.async_get(hass) for device_id in new_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - if any( - ( - config_entry := hass.config_entries.async_get_entry( - config_entry_id - ) + # The same sub-device can be reported by several config entries, each + # owning its own device registry entry. Its sensors share a unique id + # across config entries, so only create them if no other loaded config + # entry already provides them. + if any( + ( + config_entry := hass.config_entries.async_get_entry( + device.config_entry_id ) - and config_entry.state is ConfigEntryState.LOADED - for config_entry_id in device.config_entries - ): - continue + ) + and config_entry.state is ConfigEntryState.LOADED + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ) + ): + continue async_add_entities( WithingsDeviceSensor(device_coordinator, description, device_id) for description in DEVICE_SENSORS @@ -870,11 +875,17 @@ async def async_setup_entry( if old_devices: device_registry = dr.async_get(hass) for device_id in old_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) - current_devices.remove(device_id) + # Several config entries can share this identifier, each owning its own + # device registry entry, so only remove this entry's own device. + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ): + if device.config_entry_id == entry.entry_id: + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + break + current_devices.remove(device_id) device_coordinator.async_add_listener(_async_device_listener) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2edbb3c035f8..9f1e2839be95 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2133,6 +2133,7 @@ class ConfigEntries: self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) + self._initialized = asyncio.Event() EntityRegistryDisabledHandler(hass).async_setup() @callback @@ -2277,7 +2278,7 @@ class ConfigEntries: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_entry(entry_id) + dev_reg.async_clear_config_entry(entry_id, entry.domain) ent_reg.async_clear_config_entry(entry_id) # If the configuration entry is removed during reauth, it should @@ -2302,6 +2303,7 @@ class ConfigEntries: if config is None: self._entries = ConfigEntryItems(self.hass) + self._initialized.set() return entries: ConfigEntryItems = ConfigEntryItems(self.hass) @@ -2341,6 +2343,12 @@ class ConfigEntries: EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries ) + self._initialized.set() + + async def async_wait_initialized(self) -> None: + """Wait until the config entries are loaded from storage.""" + await self._initialized.wait() + async def _async_scan_orphan_ignored_entries( self, event: Event[NoEventData] ) -> None: @@ -2686,7 +2694,7 @@ class ConfigEntries: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id, entry.domain) ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id) return result diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index daf373a18624..10c015f5520d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,11 +2,15 @@ import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Set as AbstractSet +import copy +from dataclasses import dataclass from datetime import datetime from enum import StrEnum from functools import lru_cache import logging +import os +import shutil import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack, override @@ -32,7 +36,12 @@ from homeassistant.util.json import format_unserializable_data from . import storage, translation from .debounce import Debouncer from .deprecation import deprecated_function -from .frame import ReportBehavior, report_usage +from .frame import ( + MissingIntegrationFrame, + ReportBehavior, + get_integration_frame, + report_usage, +) from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .typing import UNDEFINED, UndefinedType @@ -54,8 +63,8 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event "device_registry_updated" ) STORAGE_KEY = "core.device_registry" -STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 12 +STORAGE_VERSION_MAJOR = 3 +STORAGE_VERSION_MINOR = 1 CLEANUP_DELAY = 10 @@ -66,8 +75,32 @@ CONNECTION_ZIGBEE = "zigbee" ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 -# Can be removed when suggested_area is removed from DeviceEntry -RUNTIME_ONLY_ATTRS = {"suggested_area"} +# suggested_area can be removed when suggested_area is removed from DeviceEntry. +# pending_move can be removed once add_config_entry_id and remove_config_entry_id +# are removed from the device registry API. +RUNTIME_ONLY_ATTRS = {"suggested_area", "pending_move"} + + +@dataclass(frozen=True, slots=True) +class _PendingMove: + """A deferred config-entry move recorded by add_config_entry_id. + + A later remove_config_entry_id from the same integration (origin_domain) completes + the move; one from a different integration cancels it. Runtime-only, never stored. + """ + + config_entry_id: str + config_subentry_id: str | None + origin_domain: str | None + + +def _current_integration_domain() -> str | None: + """Return the domain of the integration in the current call stack, if any.""" + try: + return get_integration_frame().integration + except MissingIntegrationFrame: + return None + CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"} @@ -102,7 +135,8 @@ class DeviceInfo(TypedDict, total=False): hw_version: str | None translation_key: str | None translation_placeholders: Mapping[str, str] | None - via_device: tuple[str, str] + via_device: tuple[str, str] # Deprecated, use via_device_id instead + via_device_id: str DEVICE_INFO_TYPES = { @@ -127,6 +161,7 @@ DEVICE_INFO_TYPES = { "suggested_area", "sw_version", "via_device", + "via_device_id", }, "secondary": { "connections", @@ -135,14 +170,10 @@ DEVICE_INFO_TYPES = { "default_name", # Used by Fritz "via_device", + "via_device_id", }, } -DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) - -# Integrations which may share a device with a native integration -LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"} - class _EventDeviceRegistryUpdatedData_Create(TypedDict): """EventDeviceRegistryUpdated data for action type 'create'.""" @@ -365,9 +396,10 @@ def _normalize_connections_validator( class DeviceEntry: """Device Registry Entry.""" + config_entry_id: str = attr.ib() + area_id: str | None = attr.ib(default=None) - config_entries: set[str] = attr.ib(converter=set, factory=set) - config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) + config_subentry_id: str | None = attr.ib(default=None) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib( converter=set, factory=set, validator=_normalize_connections_validator @@ -379,20 +411,85 @@ class DeviceEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set) labels: set[str] = attr.ib(converter=set, factory=set) + # composite_device_id is the id of the pre-migration composite device this device was + # split from; composite_primary_config_entry is that composite's former + # primary_config_entry, so a restored composite device can report it. + # split_at records when the split happened. + composite_device_id: str | None = attr.ib(default=None) + composite_primary_config_entry: str | None = attr.ib(default=None) + split_at: datetime | None = attr.ib(default=None) manufacturer: str | None = attr.ib(default=None) model: str | None = attr.ib(default=None) model_id: str | None = attr.ib(default=None) modified_at: datetime = attr.ib(factory=utcnow) name_by_user: str | None = attr.ib(default=None) name: str | None = attr.ib(default=None) - primary_config_entry: str | None = attr.ib(default=None) + # Set on devices created by splitting a pre-migration composite device: the + # identifiers and connections copied from the composite have not yet been reconciled. + # On the owning integration's first re-registration they are replaced with the ones + # it provides and this flag is cleared - a one-shot marker, unlike composite_device_id + # which is kept for the device's lifetime so old ids keep resolving; neither can be + # derived from the other. This flag and the replacement logic can be removed in HA + # Core 2027.8. + has_composite_identifiers: bool = attr.ib(default=False) serial_number: str | None = attr.ib(default=None) - # Suggested area is deprecated and will be removed from DeviceEntry in 2026.9. + # Suggested area is deprecated and will be removed from DeviceEntry in HA Core 2026.9. _suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) + # Transient pending move target (config_entry_id, config_subentry_id) initiated by + # add_config_entry_id and completed by a subsequent remove_config_entry_id. It is + # never stored and is not part of equality. Can be removed in HA Core 2027.8. + _pending_move: _PendingMove | None = attr.ib(default=None, eq=False) + # Set only on the read-only composite device that async_get synthesizes on demand + # for a pre-migration composite device id. It holds the union of the split + # devices' config entries and subentries so callers see the pre-split device. It is + # never stored and the composite is never added to the registry. Can be removed in + # HA Core 2027.8. + _composite_subentries: dict[str, set[str | None]] | None = attr.ib( + default=None, eq=False + ) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + @property + def config_entries(self) -> set[str]: + """Return the config entries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id. + """ + if self._composite_subentries is not None: + return set(self._composite_subentries) + return {self.config_entry_id} + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry and subentry, available as config_entry_id and config_subentry_id. + """ + if self._composite_subentries is not None: + return { + entry_id: set(subentries) + for entry_id, subentries in self._composite_subentries.items() + } + return {self.config_entry_id: {self.config_subentry_id}} + + @property + def primary_config_entry(self) -> str: + """Return the primary config entry of this device. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id, which is its primary config entry. + + For a restored composite device (synthesized on the fly by async_get for a + pre-migration composite device id), this returns the composite's former + primary_config_entry, which is recorded on the split devices during migration as + composite_primary_config_entry. + """ + return self.config_entry_id + @property def disabled(self) -> bool: """Return if entry is disabled.""" @@ -407,11 +504,16 @@ class DeviceEntry: return { "area_id": self.area_id, "configuration_url": self.configuration_url, + # config_entries and config_entries_subentries are deprecated and kept for + # backwards compatibility, they can be removed in HA Core 2027.8. They use the + # compatibility properties so a restored composite reports its merged entries. "config_entries": list(self.config_entries), "config_entries_subentries": { entry_id: list(subentries) for entry_id, subentries in self.config_entries_subentries.items() }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at.timestamp(), "disabled_by": self.disabled_by, @@ -455,15 +557,8 @@ class DeviceEntry: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "configuration_url": self.configuration_url, "connections": list(self.connections), "created_at": self.created_at, @@ -473,12 +568,18 @@ class DeviceEntry: "id": self.id, "identifiers": list(self.identifiers), "labels": list(self.labels), + "composite_device_id": self.composite_device_id, + "composite_primary_config_entry": ( + self.composite_primary_config_entry + ), + "split_at": self.split_at, "manufacturer": self.manufacturer, "model": self.model, "model_id": self.model_id, "modified_at": self.modified_at, "name_by_user": self.name_by_user, "name": self.name, + "has_composite_identifiers": (self.has_composite_identifiers), "primary_config_entry": self.primary_config_entry, "serial_number": self.serial_number, "sw_version": self.sw_version, @@ -496,13 +597,32 @@ class DeviceEntry: return self._suggested_area +# async_update_device arguments that redefine which identifiers/connections a device is +# keyed by, or move it to another config entry. They are ambiguous on a synthesized +# composite (there is no single underlying device to retarget), so the composite shim +# drops them with a warning instead of fanning them out. serial_number is intentionally +# NOT here: it describes the physical device and is consistent across a composite's +# splits, so it fans out like sw_version. Can be removed in HA Core 2027.8. +_COMPOSITE_IGNORED_UPDATE_ARGS = ( + "merge_connections", + "merge_identifiers", + "new_config_entry_id", + "new_config_subentry_id", + "new_connections", + "new_identifiers", +) + + @attr.s(frozen=True, slots=True) class DeletedDeviceEntry: """Deleted Device Registry Entry.""" + # config_entry_id is None for orphaned deleted devices, i.e. devices whose owning + # config entry has been removed + config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() + area_id: str | None = attr.ib() - config_entries: set[str] = attr.ib() - config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib( validator=_normalize_connections_validator ) @@ -514,8 +634,30 @@ class DeletedDeviceEntry: modified_at: datetime = attr.ib() name_by_user: str | None = attr.ib() orphaned_timestamp: float | None = attr.ib() + # Domain of the config entry that owns (or owned) this device, recorded when the + # device is deleted so a re-added config entry only restores an orphan from the same + # integration. None for legacy stores. + domain: str | None = attr.ib(default=None) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + @property + def config_entries(self) -> set[str]: + """Return the config entries this device belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + return {self.config_entry_id} if self.config_entry_id is not None else set() + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + if self.config_entry_id is None: + return {} + return {self.config_entry_id: {self.config_subentry_id}} + def to_device_entry( self, config_entry: ConfigEntry, @@ -537,9 +679,9 @@ class DeletedDeviceEntry: disabled_by = disabled_by if disabled_by is not UNDEFINED else None return DeviceEntry( area_id=self.area_id, + config_entry_id=config_entry.entry_id, + config_subentry_id=config_subentry_id, # type ignores: likely https://github.com/python/mypy/issues/8625 - config_entries={config_entry.entry_id}, # type: ignore[arg-type] - config_entries_subentries={config_entry.entry_id: {config_subentry_id}}, connections=self.connections & connections, # type: ignore[arg-type] created_at=self.created_at, disabled_by=disabled_by, @@ -556,15 +698,8 @@ class DeletedDeviceEntry: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at, "disabled_by": self.disabled_by @@ -577,11 +712,23 @@ class DeletedDeviceEntry: "modified_at": self.modified_at, "name_by_user": self.name_by_user, "orphaned_timestamp": self.orphaned_timestamp, + "domain": self.domain, } ) ) +def _copy_if_exists(source: str, destination: str) -> bool: + """Copy source to destination when source exists (runs in the executor). + + Returns whether the file was copied. + """ + if not os.path.isfile(source): + return False + shutil.copyfile(source, destination) + return True + + class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" @@ -593,10 +740,12 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): old_data: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: """Migrate to the new version.""" - # Support for a future major version bump to 2 added in HA Core 2025.2. - # Major versions 1 and 2 will be the same, except that version 2 will no - # longer store a list of config_entries. + # Note: There's no version 2, it was planned and supported by previous versions + # of the migrator which treated version 2 like version 1. if old_major_version < 3: + # Copy the store before the version 3 migrator rewrites every device, so a + # user can recover the pre-migration registry if the migration misbehaves. + await self._async_backup_store() if old_minor_version < 2: # Version 1.2 implements migration and freezes the available keys, # populate keys which were introduced before version 1.2 @@ -677,80 +826,277 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # of version 1.10 for device in old_data["deleted_devices"]: device["disabled_by_undefined"] = old_minor_version < 10 + # Version 3 restricts a device to a single config entry and subentry, + # introduced in 2026.8. Composite devices which belonged to several + # config entries (or several subentries of one entry) are split into one + # device per (config entry, subentry). Each split device keeps a copy of + # the identifiers and connections and a reference (composite_device_id) to the original + # composite device id, so that actions targeting the old id still reach + # all split devices. Entities are moved to the matching split device when + # the registries are loaded. + migrated_at = utcnow().isoformat() + devices: list[dict[str, Any]] = [] + # Ids of active devices dropped for lacking a config entry; a retained + # child's via_device_id pointing at one is detached below. + dropped_device_ids: set[str] = set() + # old composite id -> {config entry id -> new split id}, to rewrite + # via_device_id links pointing at a split parent + composite_splits: dict[str, dict[str, str]] = {} + # Active splits whose copied disabled_by must be reconciled against their + # single config entry once the config entries are loaded + migrated_active_splits: list[dict[str, Any]] = [] + for device in old_data["devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if not pairs: + # Drop devices that have no config entry / subentry pairs + dropped_device_ids.add(device["id"]) + continue + if len(pairs) == 1: + config_entry_id, subentry_id = pairs[0] + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["composite_device_id"] = None + device["composite_primary_config_entry"] = None + device["split_at"] = None + device["has_composite_identifiers"] = False + devices.append(device) + continue + old_id = device["id"] + composite_primary = device.get("primary_config_entry") + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["primary_config_entry"] = config_entry_id + split["composite_device_id"] = old_id + split["composite_primary_config_entry"] = composite_primary + split["split_at"] = migrated_at + split["has_composite_identifiers"] = True + devices.append(split) + migrated_active_splits.append(split) + composite_splits.setdefault(old_id, {})[config_entry_id] = split[ + "id" + ] + # Rewrite via_device_id links that pointed at a now-split composite parent + # to a live split: the parent's split in the child's own config entry when + # there is one, otherwise any of the parent's splits, so the link never + # dangles on the removed composite id. A link to a retained unsplit parent is + # left unchanged; a link to a dropped parent is detached below. + for device in devices: + if ( + splits := composite_splits.get(device["via_device_id"]) + ) is not None: + device["via_device_id"] = splits.get( + device["config_entry_id"], next(iter(splits.values())) + ) + elif device["via_device_id"] in dropped_device_ids: + # The parent was dropped (no config entries); detach the link as + # async_remove_device would, so it does not dangle on a removed id. + device["via_device_id"] = None + old_data["devices"] = devices + # A split inherited the composite's disabled_by, which may not match its + # single config entry (e.g. a split owned by an enabled entry must not stay + # CONFIG_ENTRY disabled). Config entries load concurrently, so wait for them + # and reconcile each split against its own entry. + if migrated_active_splits: + await self.hass.config_entries.async_wait_initialized() + for split in migrated_active_splits: + config_entry = self.hass.config_entries.async_get_entry( + split["config_entry_id"] + ) + if config_entry is not None: + _migrate_device_disabled_by( + split, config_entry.disabled_by is not None + ) + deleted_devices: list[dict[str, Any]] = [] + for device in old_data["deleted_devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if len(pairs) <= 1: + # Unlike active devices, config_entry_id=None is a valid + # (orphaned) state for a deleted device, so a deleted device with + # no config entries is kept rather than dropped. + config_entry_id, subentry_id = pairs[0] if pairs else (None, None) + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["domain"] = None + deleted_devices.append(device) + continue + # A deleted device that belonged to several config entries or subentries + # is split like an active one - each split keeps a copy of the + # identifiers/connections so every config entry can still restore its + # share when a matching device is re-registered. + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["domain"] = None + deleted_devices.append(split) + old_data["deleted_devices"] = deleted_devices + # config_entries and config_entries_subentries are deprecated; v3 stores only + # the singular config_entry_id / config_subentry_id (single-entry devices kept + # the old keys, splits copied them via deepcopy). + for migrated in (*devices, *deleted_devices): + migrated.pop("config_entries", None) + migrated.pop("config_entries_subentries", None) - if old_major_version > 2: + if old_major_version > 3: raise NotImplementedError return old_data + async def _async_backup_store(self) -> None: + """Copy the store file to a timestamped backup before migrating.""" + source = self.path + backup = f"{source}.{utcnow().strftime('%Y%m%d_%H%M%S')}.migration_backup" + try: + copied = await self.hass.async_add_executor_job( + _copy_if_exists, source, backup + ) + except OSError as err: + _LOGGER.warning("Could not back up %s before migration: %s", source, err) + else: + if copied: + _LOGGER.info("Backed up %s to %s before migration", source, backup) + class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( BaseRegistryItems[_EntryTypeT] ): """Container for device registry items, maps device id -> entry. - Maintains two additional indexes: - - (connection_type, connection identifier) -> entry - - (DOMAIN, identifier) -> entry + Maintains two additional indexes. An identifier or connection can be shared by + several devices, each belonging to a different config entry, so each index maps a + connection or identifier to the devices that have it, keyed by config entry id: + - (connection_type, connection identifier) -> {config_entry_id: entry} + - (DOMAIN, identifier) -> {config_entry_id: entry} """ def __init__(self) -> None: """Initialize the container.""" super().__init__() - self._connections: dict[tuple[str, str], _EntryTypeT] = {} - self._identifiers: dict[tuple[str, str], _EntryTypeT] = {} + self._connections: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} + self._identifiers: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} @override def _index_entry(self, key: str, entry: _EntryTypeT) -> None: """Index an entry.""" + config_entry_id = entry.config_entry_id for connection in entry.connections: - self._connections[connection] = entry + self._connections.setdefault(connection, {})[config_entry_id] = entry for identifier in entry.identifiers: - self._identifiers[identifier] = entry + self._identifiers.setdefault(identifier, {})[config_entry_id] = entry @override def _unindex_entry( self, key: str, replacement_entry: _EntryTypeT | None = None ) -> None: - """Unindex an entry.""" + """Unindex an entry. + + Guards against collisions, the code below can be simplified once + collisions are not longer allowed, refer to commit history in PR + 175785. + """ old_entry = self.data[key] + config_entry_id = old_entry.config_entry_id for connection in old_entry.connections: - if connection in self._connections: - del self._connections[connection] + by_config_entry = self._connections.get(connection) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._connections[connection] for identifier in old_entry.identifiers: - if identifier in self._identifiers: - del self._identifiers[identifier] + by_config_entry = self._identifiers.get(identifier) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._identifiers[identifier] def get_entry( self, identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, + *, + config_entry_id: str | None | UndefinedType = UNDEFINED, ) -> _EntryTypeT | None: - """Get entry from identifiers or connections.""" + """Get the first entry matching identifiers or connections. + + If config_entry_id is given, only an entry belonging to that config entry is + returned. Otherwise the first matching entry from any config entry is returned. + """ if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - return self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] if not connections: return None for connection in _normalize_connections(connections): - if connection in self._connections: - return self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] return None def get_entries( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, - ) -> Iterable[_EntryTypeT]: - """Get entries from identifiers or connections.""" + identifiers: AbstractSet[tuple[str, str]] | None = None, + connections: AbstractSet[tuple[str, str]] | None = None, + ) -> list[_EntryTypeT]: + """Get all entries matching identifiers or connections, across config entries.""" + entries: dict[str, _EntryTypeT] = {} if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - yield self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry if connections: for connection in _normalize_connections(connections): - if connection in self._connections: - yield self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry + return list(entries.values()) class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): @@ -759,16 +1105,18 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): def __init__(self) -> None: """Initialize the container. - Maintains three additional indexes: + Maintains four additional indexes: - area_id -> dict[key, True] - config_entry_id -> dict[key, True] - label -> dict[key, True] + - composite_device_id -> dict[key, True] """ super().__init__() self._area_id_index: RegistryIndexType = defaultdict(dict) self._config_entry_id_index: RegistryIndexType = defaultdict(dict) self._labels_index: RegistryIndexType = defaultdict(dict) + self._composite_device_id_index: RegistryIndexType = defaultdict(dict) @override def _index_entry(self, key: str, entry: DeviceEntry) -> None: @@ -778,8 +1126,9 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): self._area_id_index[area_id][key] = True for label in entry.labels: self._labels_index[label][key] = True - for config_entry_id in entry.config_entries: - self._config_entry_id_index[config_entry_id][key] = True + self._config_entry_id_index[entry.config_entry_id][key] = True + if entry.composite_device_id is not None: + self._composite_device_id_index[entry.composite_device_id][key] = True @override def _unindex_entry( @@ -792,8 +1141,13 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) - for config_entry_id in entry.config_entries: - self._unindex_entry_value(key, config_entry_id, self._config_entry_id_index) + self._unindex_entry_value( + key, entry.config_entry_id, self._config_entry_id_index + ) + if entry.composite_device_id is not None: + self._unindex_entry_value( + key, entry.composite_device_id, self._composite_device_id_index + ) super()._unindex_entry(key, replacement_entry) def get_devices_for_area_id(self, area_id: str) -> list[DeviceEntry]: @@ -815,12 +1169,98 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): data[key] for key in self._config_entry_id_index.get(config_entry_id, ()) ] + def get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Get the devices a pre-migration composite device was split into.""" + data = self.data + return [ + data[key] + for key in self._composite_device_id_index.get(composite_device_id, ()) + ] + + +class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]): + """Container for deleted device registry entries. + + A deleted device that still belongs to a config entry is indexed by config entry id in + the base class, like an active device. An orphaned deleted device (its config entry + removed) has no config entry id and would collide with every other orphan in the base + config_entry_id=None slot, so orphans are kept out of the base index and tracked in a + separate index keyed by device id, which is unique so orphans never shadow each other. + Orphans are matched on restore by get_orphaned_entry. + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._orphaned_connections: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + self._orphaned_identifiers: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + + @override + def _index_entry(self, key: str, entry: DeletedDeviceEntry) -> None: + """Index an entry, keeping orphans in the separate id-keyed index.""" + if entry.config_entry_id is not None: + super()._index_entry(key, entry) + return + for connection in entry.connections: + self._orphaned_connections.setdefault(connection, {})[entry.id] = entry + for identifier in entry.identifiers: + self._orphaned_identifiers.setdefault(identifier, {})[entry.id] = entry + + @override + def _unindex_entry( + self, key: str, replacement_entry: DeletedDeviceEntry | None = None + ) -> None: + """Unindex an entry from the base or the orphan index.""" + entry = self.data[key] + if entry.config_entry_id is not None: + super()._unindex_entry(key, replacement_entry) + return + for connection in entry.connections: + if connection in self._orphaned_connections: + del self._orphaned_connections[connection][entry.id] + if not self._orphaned_connections[connection]: + del self._orphaned_connections[connection] + for identifier in entry.identifiers: + if identifier in self._orphaned_identifiers: + del self._orphaned_identifiers[identifier][entry.id] + if not self._orphaned_identifiers[identifier]: + del self._orphaned_identifiers[identifier] + + def get_orphaned_entry( + self, + identifiers: set[tuple[str, str]] | None, + connections: set[tuple[str, str]] | None, + domain: str, + ) -> DeletedDeviceEntry | None: + """Return an orphan of the given domain to restore. + + Orphans are matched on their recorded domain so a chance identifier or connection + collision doesn't restore another integration's device. A domain-less orphan + (carried over by the migration with no recoverable domain) is left for the + periodic purge rather than restored. + """ + orphans: dict[str, DeletedDeviceEntry] = {} + for identifier in identifiers or (): + orphans.update(self._orphaned_identifiers.get(identifier, {})) + for connection in _normalize_connections(connections or set()): + orphans.update(self._orphaned_connections.get(connection, {})) + for entry in orphans.values(): + if entry.domain == domain: + return entry + return None + class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Class to hold a registry of devices.""" devices: ActiveDeviceRegistryItems - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] + deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -842,8 +1282,53 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): We retrieve the DeviceEntry from the underlying dict to avoid the overhead of the UserDict __getitem__. + + For a pre-migration composite device id, a read-only composite device + merged from the split devices is returned, so integration code that resolves a + device by id (e.g. in a service handler) keeps working. The composite is + synthesized on demand and never stored, so it stays invisible to enumeration, + identifier search and the frontend device list. """ - return self._device_data.get(device_id) + if (device := self._device_data.get(device_id)) is not None: + return device + if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + return self._restore_composite_device(device_id, split_devices) + return None + + @callback + def _restore_composite_device( + self, device_id: str, split_devices: list[DeviceEntry] + ) -> DeviceEntry: + """Synthesize a read-only composite device from its split devices.""" + composite_subentries: dict[str, set[str | None]] = {} + identifiers: set[tuple[str, str]] = set() + connections: set[tuple[str, str]] = set() + for split_device in split_devices: + composite_subentries.setdefault(split_device.config_entry_id, set()).add( + split_device.config_subentry_id + ) + identifiers |= split_device.identifiers + connections |= split_device.connections + # Functional identity (identifiers, connections, serial_number) is consistent + # across splits of the same physical device. Use the split owning the composite's + # former primary config entry as the base, so config_entry_id - and thus + # primary_config_entry - reports the composite's former primary. + primary_config_entry = split_devices[0].composite_primary_config_entry + base = next( + ( + split_device + for split_device in split_devices + if split_device.config_entry_id == primary_config_entry + ), + split_devices[0], + ) + return attr.evolve( + base, + composite_subentries=composite_subentries, + connections=connections, # type: ignore[arg-type] + id=device_id, + identifiers=identifiers, # type: ignore[arg-type] + ) @callback def async_get_device( @@ -851,8 +1336,100 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, ) -> DeviceEntry | None: - """Check if device is registered.""" - return self.devices.get_entry(identifiers, connections) + """Check if a device is registered. + + Identifiers and connections are unique per config entry. If several config + entries share the looked-up identifier or connection, the match is resolved to a + single device when possible - preferring the device whose config entry domain + matches the looked-up identifier. If the remaining matches are the splits of one + pre-migration composite device, a read-only composite spanning them is returned + (async_update_device and async_remove_device fan it out to the underlying + devices). Otherwise, for independent devices sharing an identifier or connection, + one owned by the calling integration is preferred, falling back to the first + match. + """ + matches = self._async_matching_devices(identifiers, connections) + if len(matches) <= 1: + return matches[0] if matches else None + # If the matches are the splits of one pre-migration composite device, return a + # read-only composite over them, reusing the composite's id so stored references + # (an automation, a fired event, or an entity holding the old device id) keep + # resolving to it as before the split. + composite_device_ids = {match.composite_device_id for match in matches} + if ( + len(composite_device_ids) == 1 + and (pre_migration_id := next(iter(composite_device_ids))) is not None + ): + return self._restore_composite_device(pre_migration_id, matches) + # Otherwise they are independent devices sharing an identifier or connection. + # Prefer one owned by the calling integration so the caller resolves to its own + # device rather than an insertion-order-dependent one; fall back to the first. + if (domain := _current_integration_domain()) is not None and ( + device := self._first_device_in_domain(matches, domain) + ) is not None: + return device + return matches[0] + + def _first_device_in_domain( + self, devices: Iterable[DeviceEntry], domain: str + ) -> DeviceEntry | None: + """Return the first device whose config entry belongs to domain.""" + for device in devices: + entry = self.hass.config_entries.async_get_entry(device.config_entry_id) + if entry is not None and entry.domain == domain: + return device + return None + + @callback + def _async_matching_devices( + self, + identifiers: AbstractSet[tuple[str, str]] | None, + connections: AbstractSet[tuple[str, str]] | None, + ) -> list[DeviceEntry]: + """Return devices matching the lookup, narrowed by identifier-domain priority.""" + matches = self.devices.get_entries(identifiers, connections) + if len(matches) > 1 and identifiers: + domains = {identifier[0] for identifier in identifiers} + preferred = [ + device + for device in matches + if ( + entry := self.hass.config_entries.async_get_entry( + device.config_entry_id + ) + ) + and entry.domain in domains + ] + if preferred: + return preferred + return matches + + @callback + def _async_device_ids_for_composite_device_id( + self, device_id: str + ) -> list[str] | None: + """Return the underlying real device ids if device_id is a composite.""" + if device_id in self.devices: + return None + if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + return [split_device.id for split_device in split_devices] + return None + + @callback + def async_get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Return the devices a composite device id represents. + + A composite device id is a pre-migration composite id - a device that belonged to + several config entries, split into one device per config entry, each keeping the + original id as composite_device_id. The underlying live devices are returned so + that actions and entity lookups targeting the composite id still reach all of + them; unmodified integrations keep the pre-rewrite behaviour, where a shared + identifier/connection resolved to a single multi-config-entry device. Returns an + empty list for a device id which is not a composite device id. + """ + return self.devices.get_devices_for_composite_device_id(composite_device_id) def _substitute_name_placeholders( self, @@ -908,7 +1485,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): sw_version: str | None | UndefinedType = UNDEFINED, translation_key: str | None = None, translation_placeholders: Mapping[str, str] | None = None, + # via_device is deprecated and will be removed in HA Core 2027.8, use + # via_device_id instead via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, + via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" default_manufacturer = _validate_str( @@ -931,6 +1511,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): f"Can't link device to unknown config entry {config_entry_id}" ) + # Validate before mutating the registry below. `via_device=None` (an explicit + # "no via device") alongside a via_device_id is contradictory, so reject it too. + if via_device is not UNDEFINED and via_device_id is not UNDEFINED: + raise HomeAssistantError( + "Passing both `via_device` and `via_device_id` is not allowed; " + "`via_device` is deprecated, pass `via_device_id` only" + ) + if ( + config_subentry_id is not UNDEFINED + and config_subentry_id is not None + and config_subentry_id not in config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {config_entry_id} has no subentry {config_subentry_id}" + ) + if translation_key: full_translation_key = ( f"component.{config_entry.domain}.device.{translation_key}.name" @@ -958,6 +1554,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("name", name), ("suggested_area", suggested_area), ("via_device", via_device), + ("via_device_id", via_device_id), *validated_fields.items(), ) if val is not UNDEFINED @@ -974,7 +1571,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): connections = _normalize_connections(connections) device = self.devices.get_entry( - identifiers=identifiers, connections=connections + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, ) is_new = False @@ -982,7 +1581,20 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if device is None: is_new = True - deleted_device = self.deleted_devices.get_entry(identifiers, connections) + deleted_device = self.deleted_devices.get_entry( + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, + ) + if deleted_device is None: + # Fall back to an orphan (its owning config entry was removed) + # so re-adding an integration restores the device id, area, labels and name + # rather than create a fresh device. Matching on the recorded domain keeps + # a chance identifier/connection collision from restoring another + # integration's device. + deleted_device = self.deleted_devices.get_orphaned_entry( + identifiers, connections, config_entry.domain + ) if deleted_device is None: area_id: str | None = None if ( @@ -995,7 +1607,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id - device = DeviceEntry(area_id=area_id) + device = DeviceEntry( + area_id=area_id, + config_entry_id=config_entry_id, + # Interpret not specifying a subentry as None + config_subentry_id=( + config_subentry_id + if config_subentry_id is not UNDEFINED + else None + ), + ) else: self.deleted_devices.pop(deleted_device.id) @@ -1024,7 +1645,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.devices.get_entry(identifiers={via_device})) is None: + # Resolve the deprecated via_device to a device id. The identifier is not + # unique across config entries, so prefer a via device in the same config + # entry, then one from the same integration (domain), falling back to any + # config entry (a via device may legitimately belong to a different config + # entry). This ambiguity is why via_device is deprecated. + via = ( + self.devices.get_entry( + identifiers={via_device}, config_entry_id=config_entry_id + ) + or self._first_device_in_domain( + self.devices.get_entries(identifiers={via_device}), + config_entry.domain, + ) + or self.devices.get_entry(identifiers={via_device}) + ) + if via is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1032,25 +1668,46 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): core_behavior=ReportBehavior.LOG, breaks_in_ha_version="2025.12.0", ) + via_device_id = via.id if via else UNDEFINED + elif via_device is None: + # An explicit `via_device=None` means "no via device" (a via_device_id + # alongside it is rejected above). + via_device_id = None - via_device_id: str | UndefinedType = via.id if via else UNDEFINED + # On the owning integration's first re-registration of a device created by + # splitting a pre-migration composite device, replace the identifiers and + # connections copied from the composite with the ones the integration provides, + # instead of merging. This block and the has_composite_identifiers flag + # can be removed in HA Core 2027.8. + identifiers_connections: dict[str, Any] + has_composite_identifiers: bool | UndefinedType = UNDEFINED + if not is_new and device.has_composite_identifiers: + identifiers_connections = { + "new_connections": connections, + "new_identifiers": identifiers, + } + has_composite_identifiers = False else: - via_device_id = UNDEFINED + identifiers_connections = { + "merge_connections": connections or UNDEFINED, + "merge_identifiers": identifiers or UNDEFINED, + } device = self._async_update_device( device.id, allow_collisions=True, - add_config_entry_id=config_entry_id, - add_config_subentry_id=config_subentry_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, is_new=is_new, - merge_connections=connections or UNDEFINED, - merge_identifiers=identifiers or UNDEFINED, name=name, + has_composite_identifiers=has_composite_identifiers, + # Move the device if the integration re-registers it under a different + # subentry; UNDEFINED leaves the subentry unchanged. Also validates an + # explicitly provided subentry for new devices. + new_config_subentry_id=config_subentry_id, suggested_area=suggested_area, via_device_id=via_device_id, + **identifiers_connections, **validated_fields, ) @@ -1071,7 +1728,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): allow_collisions: bool = False, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, - device_info_type: str | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -1084,6 +1740,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + # has_composite_identifiers can be removed in HA Core 2027.8 + has_composite_identifiers: bool | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | None | UndefinedType = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, @@ -1106,9 +1766,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs - config_entries = old.config_entries - config_entries_subentries = old.config_entries_subentries - if add_config_entry_id is not UNDEFINED: if ( add_config_entry := self.hass.config_entries.async_get_entry( @@ -1143,6 +1800,26 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Can't remove config subentry without specifying config entry" ) + if ( + new_config_entry_id is not UNDEFINED + and self.hass.config_entries.async_get_entry(new_config_entry_id) is None + ): + raise HomeAssistantError( + f"Can't move device to unknown config entry {new_config_entry_id}" + ) + + if ( + new_config_entry_id is not UNDEFINED + or new_config_subentry_id is not UNDEFINED + ) and ( + add_config_entry_id is not UNDEFINED + or remove_config_entry_id is not UNDEFINED + ): + raise HomeAssistantError( + "Can't combine new_config_entry_id or new_config_subentry_id with " + "add_config_entry_id or remove_config_entry_id" + ) + if not new_connections and not new_identifiers: raise HomeAssistantError( "A device must have at least one of identifiers or connections" @@ -1158,109 +1835,133 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Cannot define both merge_identifiers and new_identifiers" ) - if add_config_entry_id is not UNDEFINED: - if add_config_subentry_id is UNDEFINED: - # Interpret not specifying a subentry as None (the main entry) - add_config_subentry_id = None - - primary_entry_id = old.primary_config_entry - if ( - device_info_type == "primary" - and add_config_entry_id != primary_entry_id - ): - if ( - primary_entry_id is None - or not ( - primary_entry := self.hass.config_entries.async_get_entry( - primary_entry_id - ) + # A device belongs to exactly one config entry and subentry: + # - add_config_entry_id (with an optional add_config_subentry_id) records a + # transient pending move to that config entry and subentry; on its own it does + # not move the device. Integrations move a device by adding the new config + # entry and then removing the current one, often in separate calls; the removal + # of the current config entry performs the pending move. + # - remove_config_entry_id on the owning entry performs a pending move if there + # is one, otherwise it removes the device, since it has no other config entry. + # - new_config_entry_id / new_config_subentry_id move the device immediately. + target_config_entry_id: str | UndefinedType = UNDEFINED + target_config_subentry_id: str | None | UndefinedType = UNDEFINED + pending_move: _PendingMove | None | UndefinedType = UNDEFINED + if new_config_entry_id is not UNDEFINED: + target_config_entry_id = new_config_entry_id + target_config_subentry_id = ( + new_config_subentry_id + if new_config_subentry_id is not UNDEFINED + else None + ) + # An immediate move to a new config entry supersedes a deferred move from an + # earlier add_config_entry_id; clear it so a later removal of the new owner + # deletes the device instead of performing the stale move. + pending_move = None + elif new_config_subentry_id is not UNDEFINED: + target_config_subentry_id = new_config_subentry_id + else: + if add_config_entry_id is not UNDEFINED: + # Adding the config entry (and subentry) the device already belongs to is a + # no-op; recording it as a pending move would make a later removal of that + # sole owner move the device to itself instead of deleting it. + already_owner = add_config_entry_id == old.config_entry_id and ( + add_config_subentry_id is UNDEFINED + or add_config_subentry_id == old.config_subentry_id + ) + if not already_owner: + pending_move = _PendingMove( + add_config_entry_id, + add_config_subentry_id + if add_config_subentry_id is not UNDEFINED + else None, + _current_integration_domain(), ) - or primary_entry.domain in LOW_PRIO_CONFIG_ENTRY_DOMAINS - ): - new_values["primary_config_entry"] = add_config_entry_id - old_values["primary_config_entry"] = primary_entry_id - - if add_config_entry_id not in old.config_entries: - config_entries = old.config_entries | {add_config_entry_id} - config_entries_subentries = old.config_entries_subentries | { - add_config_entry_id: {add_config_subentry_id} - } - # Enable the device if it was disabled by config entry and we're adding - # a non disabled config entry + if remove_config_entry_id == old.config_entry_id and ( + remove_config_subentry_id is UNDEFINED + or remove_config_subentry_id == old.config_subentry_id + ): + move_from_prior_call = pending_move is UNDEFINED + move_target = ( + pending_move if pending_move is not UNDEFINED else old._pending_move # noqa: SLF001 + ) + # A deferred move armed by an earlier add_config_entry_id only completes + # if the integration now removing the owning entry is the one that armed + # it. A removal from a different integration (e.g. device_tracker + # attaching a shared MAC) is unrelated, so cancel the move and delete the + # device instead of silently transferring it. Origins from core/tests are + # undetermined (None) and never cancel. if ( - # mypy says add_config_entry can be None. - # That's impossible, because we raise above if - # that happens - not add_config_entry.disabled_by # type: ignore[union-attr] - and old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY + move_target is not None + and move_from_prior_call + and move_target.origin_domain is not None + and (current_domain := _current_integration_domain()) is not None + and current_domain != move_target.origin_domain ): - new_values["disabled_by"] = None - old_values["disabled_by"] = old.disabled_by - elif ( - add_config_subentry_id - not in old.config_entries_subentries[add_config_entry_id] - ): - config_entries_subentries = old.config_entries_subentries | { - add_config_entry_id: old.config_entries_subentries[ - add_config_entry_id - ] - | {add_config_subentry_id} - } - - if ( - remove_config_entry_id is not UNDEFINED - and remove_config_entry_id in config_entries - ): - if remove_config_subentry_id is UNDEFINED: - config_entries_subentries = dict(old.config_entries_subentries) - del config_entries_subentries[remove_config_entry_id] - elif ( - remove_config_subentry_id - in old.config_entries_subentries[remove_config_entry_id] - ): - config_entries_subentries = old.config_entries_subentries | { - remove_config_entry_id: old.config_entries_subentries[ - remove_config_entry_id - ] - - {remove_config_subentry_id} - } - if not config_entries_subentries[remove_config_entry_id]: - del config_entries_subentries[remove_config_entry_id] - - if remove_config_entry_id not in config_entries_subentries: - if config_entries == {remove_config_entry_id}: + move_target = None + if move_target is None: self.async_remove_device(device_id) return None + target_config_entry_id = move_target.config_entry_id + target_config_subentry_id = move_target.config_subentry_id + pending_move = None + # A pre-migration composite's splits share identity, so once one split + # completes the move to the target entry the others must not also move + # there and collide; clear their pending moves. + if old.composite_device_id is not None: + for sibling in self.devices.get_devices_for_composite_device_id( + old.composite_device_id + ): + if ( + sibling.id != device_id + and sibling._pending_move is not None # noqa: SLF001 + ): + self.devices[sibling.id] = attr.evolve( + sibling, pending_move=None + ) - if remove_config_entry_id == old.primary_config_entry: - new_values["primary_config_entry"] = None - old_values["primary_config_entry"] = old.primary_config_entry - - config_entries = config_entries - {remove_config_entry_id} - - # Disable the device if it is enabled and all remaining config entries - # are disabled - has_enabled_config_entries = any( - config_entry.disabled_by is None - for config_entry_id in config_entries - if ( - config_entry := self.hass.config_entries.async_get_entry( - config_entry_id - ) - ) - is not None + if target_config_subentry_id not in (UNDEFINED, None): + resolved_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) + resolved_config_entry = self.hass.config_entries.async_get_entry( + resolved_config_entry_id + ) + if ( + resolved_config_entry is None + or target_config_subentry_id not in resolved_config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {resolved_config_entry_id} has no" + f" subentry {target_config_subentry_id}" ) - if not has_enabled_config_entries and old.disabled_by is None: - new_values["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY - old_values["disabled_by"] = old.disabled_by - if config_entries != old.config_entries: - new_values["config_entries"] = config_entries - old_values["config_entries"] = old.config_entries + if ( + target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + ): + new_values["config_entry_id"] = target_config_entry_id + old_values["config_entry_id"] = old.config_entry_id + if ( + target_config_subentry_id is not UNDEFINED + and target_config_subentry_id != old.config_subentry_id + ): + new_values["config_subentry_id"] = target_config_subentry_id + old_values["config_subentry_id"] = old.config_subentry_id + # pending_move is a transient runtime-only attribute; it is not reported in the + # update event (not added to old_values) and never stored + if pending_move is not UNDEFINED and pending_move != old._pending_move: # noqa: SLF001 + new_values["pending_move"] = pending_move - if config_entries_subentries != old.config_entries_subentries: - new_values["config_entries_subentries"] = config_entries_subentries - old_values["config_entries_subentries"] = old.config_entries_subentries + # Identifiers and connections are unique per config entry, so when the device is + # moved to another config entry they are validated against the new one + effective_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None @@ -1268,6 +1969,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, + effective_config_entry_id, merge_connections, allow_collisions, ) @@ -1279,7 +1981,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if merge_identifiers is not UNDEFINED: merge_identifiers = self._validate_identifiers( - device_id, merge_identifiers, allow_collisions + device_id, + effective_config_entry_id, + merge_identifiers, + allow_collisions, ) old_identifiers = old.identifiers if not merge_identifiers.issubset(old_identifiers): @@ -1289,16 +1994,52 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if new_connections is not UNDEFINED: added_connections = new_values["connections"] = self._validate_connections( - device_id, new_connections, False + device_id, effective_config_entry_id, new_connections, False ) old_values["connections"] = old.connections if new_identifiers is not UNDEFINED: added_identifiers = new_values["identifiers"] = self._validate_identifiers( - device_id, new_identifiers, False + device_id, effective_config_entry_id, new_identifiers, False ) old_values["identifiers"] = old.identifiers + # On a move to another config entry, validate the identifiers and connections + # retained from the old entry against the new one, so the move can't silently + # overwrite the index slot of a device that already has the same identity there. + # A full new_identifiers / new_connections replacement is validated above; + # merge_* only adds, so the retained old values still need checking here. + if effective_config_entry_id != old.config_entry_id: + if new_identifiers is UNDEFINED: + self._validate_identifiers( + device_id, effective_config_entry_id, old.identifiers, False + ) + if new_connections is UNDEFINED: + self._validate_connections( + device_id, effective_config_entry_id, old.connections, False + ) + + # On a move, reflect the new owning config entry's disabled state (as restoring a + # deleted device does) unless disabled_by was passed explicitly: disable an + # enabled device moved onto a disabled entry, and clear a CONFIG_ENTRY disable + # when moved onto an enabled entry. A USER disable is preserved. + if ( + disabled_by is UNDEFINED + and target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + and ( + target_entry := self.hass.config_entries.async_get_entry( + target_config_entry_id + ) + ) + is not None + ): + if target_entry.disabled_by: + if old.disabled_by is None: + disabled_by = DeviceEntryDisabler.CONFIG_ENTRY + elif old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None + for attr_name, value in ( ("area_id", area_id), ("configuration_url", configuration_url), @@ -1311,6 +2052,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("model_id", model_id), ("name", name), ("name_by_user", name_by_user), + ("has_composite_identifiers", has_composite_identifiers), ("serial_number", serial_number), ("sw_version", sw_version), ("via_device_id", via_device_id), @@ -1336,13 +2078,28 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new = attr.evolve(old, **new_values) self.devices[device_id] = new - # NOTE: Once we solve the broader issue of duplicated devices, we might - # want to revisit it. Instead of simply removing the duplicated deleted device, - # we might want to merge the information from it into the non-deleted device. + # On a move, the device's whole retained identity newly appears in the target + # config entry; added_identifiers/added_connections are empty on a retained- + # identity move, so match the target entry's deleted device by the full identity. + match_identifiers: set[tuple[str, str]] | None + match_connections: set[tuple[str, str]] | None + if effective_config_entry_id != old.config_entry_id: + match_identifiers = new.identifiers + match_connections = new.connections + else: + match_identifiers = added_identifiers + match_connections = added_connections for deleted_device in self.deleted_devices.get_entries( - added_identifiers, added_connections + match_identifiers, match_connections ): - del self.deleted_devices[deleted_device.id] + # get_entries matches across config entries, but identifiers/connections are + # unique per config entry - only remove the deleted device owned by this + # device's config entry, so another entry can still restore its own. + if ( + deleted_device.config_entry_id == effective_config_entry_id + and deleted_device.id in self.deleted_devices + ): + del self.deleted_devices[deleted_device.id] # If its only run time attributes (suggested_area) # that do not get saved we do not want to write @@ -1374,7 +2131,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): add_config_subentry_id: str | None | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, - device_info_type: str | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -1386,6 +2142,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | None | UndefinedType = UNDEFINED, new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, @@ -1398,11 +2156,57 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) -> DeviceEntry | None: """Update device attributes. - :param add_config_subentry_id: Add the device to a specific - subentry of add_config_entry_id - :param remove_config_subentry_id: Remove the device from a - specific subentry of remove_config_entry_id + A device belongs to a single config entry and subentry. To move a device to + another config entry or subentry, pass new_config_entry_id and/or + new_config_subentry_id. To remove a device, pass remove_config_entry_id with the + device's config entry. + + :param add_config_entry_id: Deprecated. Combined with remove_config_entry_id it + moves the device; on its own it does nothing. + :param add_config_subentry_id: Deprecated. Combined with remove_config_subentry_id + it moves the device to another subentry; on its own it does nothing. + :param new_config_entry_id: Move the device to this config entry. + :param new_config_subentry_id: Move the device to this subentry. + :param remove_config_entry_id: Remove the device if it is the device's config + entry, unless combined with add_config_entry_id to move the device. + :param remove_config_subentry_id: Remove the device from a specific subentry of + remove_config_entry_id. """ + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + # Fan the update out to each underlying device; keep in sync with the + # update parameters above. + update_args = { + "add_config_entry_id": add_config_entry_id, + "add_config_subentry_id": add_config_subentry_id, + "area_id": area_id, + "configuration_url": configuration_url, + "disabled_by": disabled_by, + "entry_type": entry_type, + "hw_version": hw_version, + "labels": labels, + "manufacturer": manufacturer, + "merge_connections": merge_connections, + "merge_identifiers": merge_identifiers, + "model": model, + "model_id": model_id, + "name_by_user": name_by_user, + "name": name, + "new_config_entry_id": new_config_entry_id, + "new_config_subentry_id": new_config_subentry_id, + "new_connections": new_connections, + "new_identifiers": new_identifiers, + "remove_config_entry_id": remove_config_entry_id, + "remove_config_subentry_id": remove_config_subentry_id, + "serial_number": serial_number, + "suggested_area": suggested_area, + "sw_version": sw_version, + "via_device_id": via_device_id, + } + return self._async_update_composite_device( + device_id, underlying_ids, update_args + ) if suggested_area is not UNDEFINED: report_usage( "passes a suggested_area to device_registry.async_update device", @@ -1425,7 +2229,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): add_config_entry_id=add_config_entry_id, add_config_subentry_id=add_config_subentry_id, area_id=area_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, labels=labels, @@ -1433,6 +2236,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): merge_identifiers=merge_identifiers, name_by_user=name_by_user, name=name, + new_config_entry_id=new_config_entry_id, + new_config_subentry_id=new_config_subentry_id, new_connections=new_connections, new_identifiers=new_identifiers, remove_config_entry_id=remove_config_entry_id, @@ -1446,10 +2251,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def _validate_connections( self, device_id: str, + config_entry_id: str, connections: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Normalize and validate connections, raise on collision with other devices.""" + """Normalize and validate connections, raise on collision with other devices. + + Connections are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ normalized_connections = _normalize_connections(connections) if allow_collisions: return normalized_connections @@ -1459,7 +2269,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry(connections={connection}) + existing_device := self.devices.get_entry( + connections={connection}, config_entry_id=config_entry_id + ) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1471,10 +2283,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def _validate_identifiers( self, device_id: str, + config_entry_id: str, identifiers: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Validate identifiers, raise on collision with other devices.""" + """Validate identifiers, raise on collision with other devices. + + Identifiers are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ if allow_collisions: return identifiers @@ -1483,21 +2300,70 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry(identifiers={identifier}) + existing_device := self.devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) return identifiers + @callback + def _async_update_composite_device( + self, + composite_id: str, + underlying_ids: list[str], + update_args: dict[str, Any], + ) -> DeviceEntry | None: + """Fan an async_update_device call on a composite out to its real devices.""" + forward = { + name: value for name, value in update_args.items() if value is not UNDEFINED + } + if ignored := [ + name for name in _COMPOSITE_IGNORED_UPDATE_ARGS if name in forward + ]: + # These rewrite a device's functional identity or move it, which is ambiguous + # across the composite's underlying devices; drop them rather than corrupt or + # collide, and report the offending integration. + report_usage( + f"passed {', '.join(ignored)} to device_registry.async_update_device " + "for a composite device that spans several config entries (returned for " + "an ambiguous async_get_device lookup, or " + "resolved from a stored device id of a pre-migration composite); the " + "argument cannot be applied to the merged device and was ignored - " + "target a single device, e.g. one returned by " + "async_entries_for_config_entry", + core_behavior=ReportBehavior.LOG, + ) + for name in ignored: + del forward[name] + for underlying_id in underlying_ids: + self.async_update_device(underlying_id, **forward) + remaining = [ + self.devices[underlying_id] + for underlying_id in underlying_ids + if underlying_id in self.devices + ] + if not remaining: + return None + return self._restore_composite_device(composite_id, remaining) + @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + for underlying_id in underlying_ids: + self.async_remove_device(underlying_id) + return self.hass.verify_event_loop_thread("device_registry.async_remove_device") device = self.devices.pop(device_id) + config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id) self.deleted_devices[device_id] = DeletedDeviceEntry( area_id=device.area_id, - config_entries=device.config_entries, - config_entries_subentries=device.config_entries_subentries, + config_entry_id=device.config_entry_id, + config_subentry_id=device.config_subentry_id, connections=device.connections, created_at=device.created_at, disabled_by=device.disabled_by, @@ -1507,6 +2373,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): modified_at=utcnow(), name_by_user=device.name_by_user, orphaned_timestamp=None, + domain=config_entry.domain if config_entry is not None else None, ) for other_device in list(self.devices.values()): if other_device.via_device_id == device_id: @@ -1530,19 +2397,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): data = await self._store.async_load() devices = ActiveDeviceRegistryItems() - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] = DeviceRegistryItems() + deleted_devices = DeletedDeviceRegistryItems() if data is not None: for device in data["devices"]: devices[device["id"]] = DeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries_subentries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={ @@ -1567,13 +2429,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for iden in device["identifiers"] }, labels=set(device["labels"]), + composite_device_id=device["composite_device_id"], + composite_primary_config_entry=device[ + "composite_primary_config_entry" + ], + split_at=( + datetime.fromisoformat(device["split_at"]) + if device["split_at"] + else None + ), manufacturer=device["manufacturer"], model=device["model"], model_id=device["model_id"], modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], name=device["name"], - primary_config_entry=device["primary_config_entry"], + has_composite_identifiers=device["has_composite_identifiers"], serial_number=device["serial_number"], sw_version=device["sw_version"], via_device_id=device["via_device_id"], @@ -1596,13 +2467,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), disabled_by=get_optional_enum( @@ -1616,6 +2482,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], orphaned_timestamp=device["orphaned_timestamp"], + domain=device["domain"], ) self.devices = devices @@ -1645,83 +2512,110 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): } @callback - def async_clear_config_entry(self, config_entry_id: str) -> None: + def _resolve_orphan_domain( + self, config_entry_id: str, domain: str | None + ) -> str | None: + """Return the domain to record on devices orphaned from a config entry.""" + if domain is not None: + return domain + if ( + entry := self.hass.config_entries.async_get_entry(config_entry_id) + ) is not None: + return entry.domain + return None + + @callback + def _async_orphan_deleted_device( + self, deleted_device: DeletedDeviceEntry, domain: str | None, now_time: float + ) -> None: + """Mark a deleted device as orphaned, remembering its former domain.""" + if domain is not None: + # Orphans are indexed by their recorded domain, so two orphans of the + # same domain sharing an identifier or connection would collide. When a + # device from the same integration is orphaned, drop any existing orphan + # it overlaps so the newest one wins deterministically instead of shadowing + # it. + for existing in list(self.deleted_devices.values()): + if ( + existing.config_entry_id is None + and existing.domain == domain + and ( + existing.connections & deleted_device.connections + or existing.identifiers & deleted_device.identifiers + ) + ): + del self.deleted_devices[existing.id] + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + config_entry_id=None, + config_subentry_id=None, + orphaned_timestamp=now_time, + domain=domain, + ) + self.async_schedule_save() + + @callback + def async_clear_config_entry( + self, config_entry_id: str, domain: str | None = None + ) -> None: """Clear config entry from registry entries.""" + domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() for device in self.devices.get_devices_for_config_entry_id(config_entry_id): - self._async_update_device(device.id, remove_config_entry_id=config_entry_id) + self.async_remove_device(device.id) + # A split device records the composite's former primary config entry; when that + # config entry is removed, clear the now-dangling reference so a restored + # composite no longer points at a config entry that no longer exists. + for device in list(self.devices.values()): + if device.composite_primary_config_entry == config_entry_id: + self.devices[device.id] = attr.evolve( + device, composite_primary_config_entry=None + ) + self.async_schedule_save() + # A device owned by another config entry may hold a transient pending move + # targeting the entry being removed; clear it so a later completion deletes the + # device instead of moving it onto the removed entry. + for device in list(self.devices.values()): + pending_move = device._pending_move # noqa: SLF001 + if ( + pending_move is not None + and pending_move.config_entry_id == config_entry_id + ): + self.devices[device.id] = attr.evolve(device, pending_move=None) for deleted_device in list(self.deleted_devices.values()): - config_entries = deleted_device.config_entries - if config_entry_id not in config_entries: + if deleted_device.config_entry_id != config_entry_id: continue - if config_entries == {config_entry_id}: - # Add a time stamp when the deleted device became orphaned - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - orphaned_timestamp=now_time, - config_entries=set(), - config_entries_subentries={}, - ) - else: - config_entries = config_entries - {config_entry_id} - config_entries_subentries = dict( - deleted_device.config_entries_subentries - ) - del config_entries_subentries[config_entry_id] - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_clear_config_subentry( - self, config_entry_id: str, config_subentry_id: str + self, config_entry_id: str, config_subentry_id: str, domain: str | None = None ) -> None: - """Clear config entry from registry entries.""" + """Clear config subentry from registry entries.""" + domain = self._resolve_orphan_domain(config_entry_id, domain) now_time = time.time() for device in self.devices.get_devices_for_config_entry_id(config_entry_id): - self._async_update_device( - device.id, - remove_config_entry_id=config_entry_id, - remove_config_subentry_id=config_subentry_id, - ) - for deleted_device in list(self.deleted_devices.values()): - config_entries = deleted_device.config_entries - config_entries_subentries = deleted_device.config_entries_subentries + if device.config_subentry_id != config_subentry_id: + continue + self.async_remove_device(device.id) + # A device may hold a transient pending move targeting the subentry being removed; + # clear it so a later completion deletes the device instead of validating against + # the removed subentry. + for device in list(self.devices.values()): + pending_move = device._pending_move # noqa: SLF001 if ( - config_entry_id not in config_entries_subentries - or config_subentry_id not in config_entries_subentries[config_entry_id] + pending_move is not None + and pending_move.config_entry_id == config_entry_id + and pending_move.config_subentry_id == config_subentry_id + ): + self.devices[device.id] = attr.evolve(device, pending_move=None) + for deleted_device in list(self.deleted_devices.values()): + if ( + deleted_device.config_entry_id != config_entry_id + or deleted_device.config_subentry_id != config_subentry_id ): continue - if config_entries_subentries == {config_entry_id: {config_subentry_id}}: - # We're removing the last config subentry from the last config - # entry, add a time stamp when the deleted device became orphaned - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - orphaned_timestamp=now_time, - config_entries=set(), - config_entries_subentries={}, - ) - else: - config_entries_subentries = config_entries_subentries | { - config_entry_id: config_entries_subentries[config_entry_id] - - {config_subentry_id} - } - if not config_entries_subentries[config_entry_id]: - del config_entries_subentries[config_entry_id] - config_entries = config_entries - {config_entry_id} - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_purge_expired_orphaned_devices(self) -> None: @@ -1821,7 +2715,6 @@ def async_config_entry_disabled_by_changed( the config entry is disabled, enable devices in the registry that are associated with a config entry when the config entry is enabled and the devices are marked DeviceEntryDisabler.CONFIG_ENTRY. - Only disable a device if all associated config entries are disabled. """ devices = async_entries_for_config_entry(registry, config_entry.entry_id) @@ -1833,25 +2726,37 @@ def async_config_entry_disabled_by_changed( registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001 return - enabled_config_entries = { - entry.entry_id - for entry in registry.hass.config_entries.async_entries() - if not entry.disabled_by - } - for device in devices: if device.disabled: # Device already disabled, do not overwrite continue - if len(device.config_entries) > 1 and device.config_entries.intersection( - enabled_config_entries - ): - continue registry._async_update_device( # noqa: SLF001 device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY ) +@callback +def _migrate_device_disabled_by( + device: dict[str, Any], config_entry_disabled: bool +) -> None: + """Reconcile a stored device's disabled_by with its config entry's disabled state. + + Reimplements async_config_entry_disabled_by_changed on stored data so the 1.13 + migration can fix a split device that inherited the composite's disabled_by. Kept in + lockstep with that function by test_migrate_device_disabled_by_matches_runtime; can be + removed in HA Core 2027.8. + """ + disabled_by = device["disabled_by"] + if not config_entry_disabled: + # Config entry enabled: drop a config-entry disable, keep a user/integration one + if disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: + device["disabled_by"] = None + return + # Config entry disabled: disable the device unless it is already disabled + if disabled_by is None: + device["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY + + @callback def async_cleanup( hass: HomeAssistant, @@ -1864,8 +2769,7 @@ def async_cleanup( references_config_entries = { device.id for device in dev_reg.devices.values() - for config_entry_id in device.config_entries - if config_entry_id in config_entry_ids + if device.config_entry_id in config_entry_ids } # Find all devices that are referenced in the entity registry. @@ -1883,11 +2787,10 @@ def async_cleanup( # Find all referenced config entries that no longer exist # This shouldn't happen but have not been able to track down the bug :( for device in list(dev_reg.devices.values()): - for config_entry_id in device.config_entries: - if config_entry_id not in config_entry_ids: - dev_reg._async_update_device( # noqa: SLF001 - device.id, remove_config_entry_id=config_entry_id - ) + if device.config_entry_id not in config_entry_ids: + dev_reg._async_update_device( # noqa: SLF001 + device.id, remove_config_entry_id=device.config_entry_id + ) # Periodic purge of orphaned devices to avoid the registry # growing without bounds when there are lots of deleted devices diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3683385f7b0a..9d3cd41e329f 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -934,9 +934,14 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): Also maintains a count of enabled entries per config entry id. """ - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the container.""" super().__init__() + # hass is stored only so get_entries_for_device_id can expand a pre-migration + # composite device id to its split devices. Remove it, and restore the no-argument + # constructor, once the device registry deprecation period is over and composite + # device ids are no longer resolved. + self._hass = hass self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} self._config_entry_id_index: RegistryIndexType = defaultdict(dict) @@ -1002,13 +1007,44 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): return self._entry_ids.get(key) def get_entries_for_device_id( - self, device_id: str, include_disabled_entities: bool = False + self, + device_id: str, + include_disabled_entities: bool = False, ) -> list[RegistryEntry]: - """Get entries for device.""" + """Get entries for device. + + A device_id may be a pre-migration composite device id, which was split into one + device per config entry. The entries of the split devices are included, so a + lookup by the old composite id still finds the entities that were moved to the + split devices. + """ data = self.data + device_registry = dr.async_get(self._hass) + if device_id in device_registry.devices: + # Fast path: a live device id resolves directly to its own entities + return [ + entry + for key in self._device_id_index.get(device_id, ()) + if not (entry := data[key]).disabled_by or include_disabled_entities + ] + # A pre-migration composite device id resolves to the entities of the split + # devices it was migrated into. device_id is kept in the list because the slow + # path is also hit for a device that was just removed (no longer in + # device_registry.devices) whose entities still need to be found - e.g. when the + # entity registry prunes the entities of a removed device. + device_ids = [ + device_id, + *( + device.id + for device in device_registry.async_get_devices_for_composite_device_id( + device_id + ) + ), + ] return [ entry - for key in self._device_id_index.get(device_id, ()) + for a_device_id in device_ids + for key in self._device_id_index.get(a_device_id, ()) if not (entry := data[key]).disabled_by or include_disabled_entities ] @@ -1629,36 +1665,32 @@ class EntityRegistry(BaseRegistry): changes = event.data["changes"] - # Remove entities which belong to config entries no longer associated with the - # device - if old_config_entries := changes.get("config_entries"): + # Remove entities which belong to the config entry the device no longer belongs + # to. changes carries the old config_entry_id only when it changed (a move). + if "config_entry_id" in changes: + old_config_entry_id = changes["config_entry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id if ( - entity.config_entry_id in old_config_entries - and entity.config_entry_id not in device.config_entries + entity.config_entry_id == old_config_entry_id + and entity.config_entry_id != device.config_entry_id ): self.async_remove(entity.entity_id) - # Remove entities which belong to config subentries no longer - # associated with the device - if old_config_entries_subentries := changes.get("config_entries_subentries"): + # Remove entities which belong to the config subentry the device no longer + # belongs to. changes carries the old config_subentry_id only when it changed. + if "config_subentry_id" in changes: + old_config_subentry_id = changes["config_subentry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id - config_subentry_id = entity.config_subentry_id if ( - config_entry_id in device.config_entries - and config_entry_id in old_config_entries_subentries - and config_subentry_id - in old_config_entries_subentries[config_entry_id] - and config_subentry_id - not in device.config_entries_subentries[config_entry_id] + entity.config_entry_id == device.config_entry_id + and entity.config_subentry_id == old_config_subentry_id + and entity.config_subentry_id != device.config_subentry_id ): self.async_remove(entity.entity_id) @@ -2011,16 +2043,53 @@ class EntityRegistry(BaseRegistry): async def _async_load(self) -> None: """Load the entity registry.""" # Device registry must be loaded before entity registry because - # migration and entity processing reference device names. - await dr.async_get(self.hass).async_wait_loaded() + # migration and entity processing reference device names, and because entities + # are moved to the correct device when a pre-migration composite device was + # split into one device per config entry. + device_registry = dr.async_get(self.hass) + await device_registry.async_wait_loaded() _async_setup_cleanup(self.hass, self) _async_setup_entity_restore(self.hass, self) data = await self._store.async_load() - entities = EntityRegistryItems() + entities = EntityRegistryItems(self.hass) deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {} + # Move entities to the correct device when a pre-migration composite device was + # split into one device per config entry. This can be removed 12 months after + # the config entries split migration ships. + migrated_composite_device = False + + def _split_device_id( + device_id: str | None, + config_entry_id: str | None, + config_subentry_id: str | None, + ) -> str | None: + """Map a device id to the split device matching the entity's config entry.""" + # Note: check container membership, not async_get, which returns a restored + # composite for a composite device id + if device_id is None or device_id in device_registry.devices: + return device_id + successors = device_registry.async_get_devices_for_composite_device_id( + device_id + ) + if not successors: + # The device is gone (e.g. the migration dropped a device with no config + # entry) and was not split; detach the entity rather than leave it pointing + # at a device id that no longer exists. + return None + for successor in successors: + if ( + successor.config_entry_id == config_entry_id + and successor.config_subentry_id == config_subentry_id + ): + return successor.id + for successor in successors: + if successor.config_entry_id == config_entry_id: + return successor.id + return successors[0].id + if data is not None: for entity in data["entities"]: try: @@ -2048,11 +2117,19 @@ class EntityRegistry(BaseRegistry): ) continue + device_id = _split_device_id( + entity["device_id"], + entity["config_entry_id"], + entity["config_subentry_id"], + ) + if device_id != entity["device_id"]: + migrated_composite_device = True + original_name_unprefixed = _unprefix_original_name( self.hass, entity["original_name"], entity["has_entity_name"], - entity["device_id"], + device_id, ) entities[entity["entity_id"]] = RegistryEntry( @@ -2065,7 +2142,7 @@ class EntityRegistry(BaseRegistry): config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], - device_id=entity["device_id"], + device_id=device_id, disabled_by=RegistryEntryDisabler(entity["disabled_by"]) if entity["disabled_by"] else None, @@ -2164,6 +2241,10 @@ class EntityRegistry(BaseRegistry): self.entities = entities self._entities_data = entities.data + # Persist entities moved off a split pre-migration composite device + if migrated_composite_device: + self.async_schedule_save() + @override def _data_to_save(self) -> dict[str, Any]: """Return data of entity registry to store in a file.""" @@ -2300,7 +2381,11 @@ async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False ) -> list[RegistryEntry]: - """Return entries that match a device.""" + """Return entries that match a device. + + A pre-migration composite device id resolves to the entries of the devices it was + split into. + """ return registry.entities.get_entries_for_device_id( device_id, include_disabled_entities ) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 87eb7041699b..d34151002f11 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -206,8 +206,19 @@ def async_extract_referenced_entity_ids( selected.missing_areas.add(area_id) for device_id in target_selection.device_ids: - if device_id not in dev_reg.devices: + if device_id in dev_reg.devices: + selected.referenced_devices.add(device_id) + elif split_devices := dev_reg.async_get_devices_for_composite_device_id( + device_id + ): + # A multi config entry composite device id is no longer a device itself; + # it resolves to the devices it was split into so actions targeting it + # still trickle down. Only the splits are referenced, not the composite id, + # so a device-id consumer does not act on the same underlying device twice. + selected.referenced_devices.update(device.id for device in split_devices) + else: selected.missing_devices.add(device_id) + selected.referenced_devices.add(device_id) if target_selection.label_ids: label_reg = lr.async_get(hass) @@ -234,7 +245,6 @@ def async_extract_referenced_entity_ids( ) selected.referenced_areas.update(target_selection.area_ids) - selected.referenced_devices.update(target_selection.device_ids) if not selected.referenced_areas and not selected.referenced_devices: return selected diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 8ca2ef7fef11..173d792ba6e3 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -11,6 +11,7 @@ from homeassistant import runner from homeassistant.auth import auth_manager_from_config from homeassistant.auth.providers import homeassistant as hass_auth from homeassistant.config import get_default_config_dir +from homeassistant.config_entries import ConfigEntries from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -55,6 +56,9 @@ def run(args: Sequence[str] | None) -> None: async def run_command(args: argparse.Namespace) -> None: """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 525201f80b75..635bbca59614 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -300,6 +300,8 @@ async def async_check_config(config_dir): hass = core.HomeAssistant(config_dir) loader.async_setup(hass) hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await ar.async_load(hass) await dr.async_load(hass) diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index cb96c9396c2b..df30a4b766dc 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -204,7 +204,14 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None: }, ) device_registry = mock_device_registry( - hass, {"mock-dev-id": DeviceEntry(id="mock-dev-id", area_id="mock-area-id")} + hass, + { + "mock-dev-id": DeviceEntry( + config_entry_id="mock-config-entry", + id="mock-dev-id", + area_id="mock-area-id", + ) + }, ) policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}} diff --git a/tests/common.py b/tests/common.py index 474863bfaa17..60000058bff4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -292,6 +292,7 @@ async def async_test_home_assistant( ) }, ) + hass.config_entries._initialized.set() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, hass.config_entries._async_shutdown, @@ -677,7 +678,7 @@ def mock_registry( if mock_entries is None: mock_entries = {} registry.deleted_entities = {} - registry.entities = er.EntityRegistryItems() + registry.entities = er.EntityRegistryItems(hass) registry._entities_data = registry.entities.data for key, entry in mock_entries.items(): registry.entities[key] = entry @@ -763,7 +764,7 @@ def mock_device_registry( mock_entries = {} for key, entry in mock_entries.items(): registry.devices[key] = entry - registry.deleted_devices = dr.DeviceRegistryItems() + registry.deleted_devices = dr.DeletedDeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry return registry diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 1a500da5ea85..7d63e61a3aa1 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -158,7 +158,9 @@ async def test_invalid_parameters( """Test invalid service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -214,7 +216,9 @@ async def test_invalid_info_skillparameters( """Test invalid info skill service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -278,21 +282,21 @@ async def test_config_entry_not_loaded( async def test_invalid_config_entry( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that a non-existing entry ID in device config entries is skipped.""" + """Test that a device pointing to a non-existing config entry ID is skipped.""" - await setup_integration(hass, mock_config_entry) - - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + device_entry = dr.DeviceEntry( + config_entry_id="non_existing_entry_id", + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) - assert device_entry - - device_entry.config_entries.clear() - device_entry.config_entries.add("non_existing_entry_id") + mock_device_registry( + hass, + {device_entry.id: device_entry}, + ) + await setup_integration(hass, mock_config_entry) with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( @@ -300,14 +304,14 @@ async def test_invalid_config_entry( "send_sound", { ATTR_SOUND: "bell_02", - ATTR_DEVICE_ID: device_entry.id, + ATTR_DEVICE_ID: TEST_DEVICE_1_ID, }, blocking=True, ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device_entry.id} + assert exc_info.value.translation_placeholders == {"device_id": TEST_DEVICE_1_ID} async def test_missing_config_entry( @@ -316,7 +320,7 @@ async def test_missing_config_entry( mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test missing config entry.""" + """Test that a device not owned by an Alexa config entry is rejected.""" await setup_integration(hass, mock_config_entry) @@ -325,7 +329,15 @@ async def test_missing_config_entry( ) assert device_entry - device_entry.config_entries.clear() + # Move the device to a config entry from a different integration + other_entry = MockConfigEntry(domain="other_domain", data={}) + other_entry.add_to_hass(hass) + device_registry.async_update_device( + device_entry.id, add_config_entry_id=other_entry.entry_id + ) + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=mock_config_entry.entry_id + ) # Call Service with pytest.raises(ServiceValidationError) as exc_info: diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 3c1505ff54f3..7a2b1379dcb6 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -716,7 +716,7 @@ async def test_migration_from_v2_1_to_v2_2( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dcd7b1faa835..2864c85a3ddb 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -331,10 +331,14 @@ def target_calendars( label_on_devices = label_registry.async_create("label_on_devices") device_calendar_1 = dr.DeviceEntry( - id="device_calendar_1", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_1", + labels=[label_on_devices.label_id], ) device_calendar_2 = dr.DeviceEntry( - id="device_calendar_2", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_2", + labels=[label_on_devices.label_id], ) mock_device_registry( hass, diff --git a/tests/components/common.py b/tests/components/common.py index c8e0a869aa4d..98087ae1e18d 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -90,7 +90,12 @@ async def target_entities( "Test Label" ) - device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id}) + device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="test_device", + area_id=area.id, + labels={label.label_id}, + ) mock_device_registry(hass, {device.id: device}) entity_reg = er.async_get(hass) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 4c0f5f18e3bc..153d4f5c685f 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -61,6 +61,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -84,6 +86,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [], "created_at": utcnow().timestamp(), @@ -119,6 +123,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -307,7 +313,7 @@ async def test_remove_config_entry_from_device( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -315,11 +321,14 @@ async def test_remove_config_entry_from_device( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -327,26 +336,21 @@ async def test_remove_config_entry_from_device( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } + async def test_remove_config_entry_from_device_fails( hass: HomeAssistant, @@ -396,38 +400,38 @@ async def test_remove_config_entry_from_device_fails( entry_3.supports_remove_device = True entry_3.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_3 = device_registry.async_get_or_create( config_entry_id=entry_3.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - entry_1.entry_id, - entry_2.entry_id, - entry_3.entry_id, - } + # Identifiers and connections are unique per config entry, so each config entry + # gets its own device even though they share a connection + assert device_entry_1.config_entries == {entry_1.entry_id} + assert device_entry_2.config_entries == {entry_2.entry_id} + assert device_entry_3.config_entries == {entry_3.entry_id} fake_entry_id = "abc123" assert entry_1.entry_id != fake_entry_id fake_device_id = "abc123" - assert device_entry.id != fake_device_id + assert device_entry_3.id != fake_device_id # Try removing a non existing config entry from the device - response = await ws_client.remove_device(device_entry.id, fake_entry_id) + response = await ws_client.remove_device(device_entry_3.id, fake_entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry_1.id, entry_1.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -443,22 +447,22 @@ async def test_remove_config_entry_from_device_fails( assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) - - assert response["success"] - assert set(response["result"]["config_entries"]) == { - entry_1.entry_id, - entry_3.entry_id, - } - - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" + # Removing a config entry which supports removal removes the device, since it is + # the device's only config entry + response = await ws_client.remove_device(device_entry_2.id, entry_2.entry_id) + + assert response["success"] + assert response["result"] is None + assert not device_registry.async_get(device_entry_2.id) + # Try removing a config entry which can't be loaded from a device - allowed - response = await ws_client.remove_device(device_entry.id, entry_3.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_3.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -517,7 +521,7 @@ async def test_remove_config_entry_from_device_if_integration_remove( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -525,11 +529,14 @@ async def test_remove_config_entry_from_device_if_integration_remove( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -537,22 +544,17 @@ async def test_remove_config_entry_from_device_if_integration_remove( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index f5330670ddd0..0208c1e9dce1 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -137,18 +137,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, derivative_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the derivative config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source device is not removed when the source entity is removed.""" assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() @@ -160,15 +152,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.derivative.async_unload_entry", wraps=derivative.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -177,8 +166,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") assert derivative_entity_entry.device_id is None - # Check that the derivative config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed @@ -380,7 +370,7 @@ async def test_migration_1_2( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.2 removes derivative config entry from device.""" + """Test migration from v1.2 keeps the derivative entity linked to the source device.""" derivative_config_entry = MockConfigEntry( data={}, @@ -399,22 +389,13 @@ async def test_migration_1_2( ) derivative_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() assert derivative_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the derivative config entry is not on the source device and the + # derivative entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index d54da57b38af..367a327b81a7 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,5 +1,6 @@ """The test for light device automation.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import attr @@ -11,14 +12,23 @@ from homeassistant import loader from homeassistant.components import automation, device_automation from homeassistant.components.device_automation import ( DOMAIN, + DeviceAutomationType, InvalidDeviceAutomationConfig, toggle_entity, ) +from homeassistant.components.device_automation.helpers import ( + _resolve_device_id, + async_validate_device_automation_config, +) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound from homeassistant.requirements import RequirementsNotFound @@ -1745,3 +1755,137 @@ async def test_async_get_device_automations_platform_reraises_exceptions( await device_automation.async_get_device_automation_platform( hass, "test", device_automation.DeviceAutomationType.TRIGGER ) + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_device_automation_resolves_legacy_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device automation legacy id resolves to the split owning its domain's entry. + + Automations for an entity platform domain are left as the composite id, which the + restored composite device and async_entries_for_device handle directly. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + await ar.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + + # A config-entry domain resolves to the split owning that domain's config entry + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_a") == by_entry[entry_a.entry_id] + ) + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_b") == by_entry[entry_b.entry_id] + ) + + # An entity platform domain is left unresolved, even when a split has such entities + entity_registry.async_get_or_create( + "light", + "domain_a", + "unique", + config_entry=entry_a, + device_id=by_entry[entry_a.entry_id], + ) + assert _resolve_device_id(hass, COMPOSITE_ID, "light") == COMPOSITE_ID + + # An unknown domain is returned unchanged + assert _resolve_device_id(hass, COMPOSITE_ID, "not_present") == COMPOSITE_ID + + +async def test_validate_config_rewrites_composite_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration: None, +) -> None: + """Validating a device automation rewrites a composite id to its domain's split.""" + fake_entry = MockConfigEntry(domain="fake_integration") + fake_entry.add_to_hass(hass) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device_fake = device_registry.async_get_or_create( + config_entry_id=fake_entry.entry_id, identifiers={("fake_integration", "1")} + ) + device_other = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("other", "1")} + ) + entity = entity_registry.async_get_or_create( + "light", "fake_integration", "u", device_id=device_fake.id + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_fake.id] = attr.evolve( + device_fake, composite_device_id=old_id + ) + device_registry.devices[device_other.id] = attr.evolve( + device_other, composite_device_id=old_id + ) + assert old_id not in device_registry.devices + + validated = await async_validate_device_automation_config( + hass, + { + "platform": "device", + "domain": "fake_integration", + "device_id": old_id, + "entity_id": entity.entity_id, + "type": "turned_on", + }, + vol.Schema( + {vol.Required("device_id"): str, vol.Required("domain"): str}, + extra=vol.ALLOW_EXTRA, + ), + DeviceAutomationType.TRIGGER, + ) + assert validated["device_id"] == device_fake.id diff --git a/tests/components/diagnostics/test_util.py b/tests/components/diagnostics/test_util.py index 6f1c1b2e1995..004d4d6f904e 100644 --- a/tests/components/diagnostics/test_util.py +++ b/tests/components/diagnostics/test_util.py @@ -5,8 +5,10 @@ from datetime import datetime from homeassistant.components.diagnostics import ( REDACTED, async_redact_data, + device_entry_as_dict, entity_entry_as_dict, ) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry @@ -88,3 +90,35 @@ def test_entity_entry_as_dict() -> None: assert result["original_name"] == "Test Sensor" assert result["supported_features"] == 0 assert result["created_at"] == created + + +def test_device_entry_as_dict() -> None: + """Test device_entry_as_dict.""" + created = datetime.fromisoformat("2024-01-01T00:00:00+00:00") + entry = DeviceEntry( + config_entry_id="mock-config-entry-id", + created_at=created, + identifiers={("test", "unique123")}, + modified_at=created, + name="Test Device", + ) + + result = device_entry_as_dict(entry) + + assert isinstance(result, dict) + # Internal bookkeeping and composite-device migration attributes are excluded + for attribute in ( + "_cache", + "_composite_subentries", + "_pending_move", + "_suggested_area", + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", + ): + assert attribute not in result + assert result["config_entry_id"] == "mock-config-entry-id" + assert result["identifiers"] == [["test", "unique123"]] + assert result["name"] == "Test Device" + assert result["created_at"] == created diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index b27e00a747cd..dee465efea12 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -30,14 +30,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -57,7 +51,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -284,14 +277,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -311,7 +298,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -944,14 +930,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -971,7 +951,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -1198,14 +1177,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1225,7 +1198,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -1918,14 +1890,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1945,7 +1911,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -2172,14 +2137,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2199,7 +2158,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -2921,14 +2879,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2948,7 +2900,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -3491,14 +3442,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ list([ @@ -3522,7 +3467,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -3844,14 +3788,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -3871,7 +3809,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -4414,14 +4351,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4441,7 +4372,6 @@ 'model_id': None, 'name': 'Collar 482520020939', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482520020939', 'sw_version': '3.0.6-D0', }), @@ -4725,14 +4655,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4752,7 +4676,6 @@ 'model_id': None, 'name': 'C6 Combiner 482523040549', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482523040549', 'sw_version': '0.1.20-D1', }), @@ -4852,14 +4775,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4879,7 +4796,6 @@ 'model_id': None, 'name': 'Enpower 654321', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '654321', 'sw_version': '1.2.2064_release/20.34', }), @@ -5273,14 +5189,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -5300,7 +5210,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.1.2', }), @@ -18167,14 +18076,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18194,7 +18097,6 @@ 'model_id': None, 'name': 'Encharge <>56', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>56', 'sw_version': '2.6.5973_rel/22.11', }), @@ -18543,14 +18445,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18570,7 +18466,6 @@ 'model_id': None, 'name': 'NC1 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -18956,14 +18851,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18983,7 +18872,6 @@ 'model_id': None, 'name': 'NC2 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -19369,14 +19257,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -19396,7 +19278,6 @@ 'model_id': None, 'name': 'NC3 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 21c1561484aa..d89232e9365f 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -242,13 +242,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_hygrostat_config_entry.entry_id ) @@ -266,28 +259,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_hygrostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_hygrostat.async_unload_entry", wraps=generic_hygrostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_hygrostat_entity_entry = entity_registry.async_get( "humidifier.my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_hygrostat config entry is not in the device + # Check that the source device is not removed and the generic_hygrostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed @@ -541,7 +532,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_hygrostat_config_entry = MockConfigEntry( data={}, @@ -560,21 +551,12 @@ async def test_migration_1_1( ) generic_hygrostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 51e996c22c7c..5ed1c5a1d524 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -247,13 +247,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_thermostat_config_entry.entry_id ) @@ -271,28 +264,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_thermostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_thermostat.async_unload_entry", wraps=generic_thermostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_thermostat_entity_entry = entity_registry.async_get( "climate.my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_thermostat config entry is not in the device + # Check that the source device is not removed and the generic_thermostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed @@ -554,7 +545,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_thermostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_thermostat_config_entry = MockConfigEntry( data={}, @@ -573,21 +564,12 @@ async def test_migration_1_1( ) generic_thermostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_thermostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 97861c9782ad..1aab55c30e42 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -755,7 +755,7 @@ async def test_migration_from_v1_with_same_keys( ( {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, [], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: # Wrong device registry, TTS subentry created @@ -770,7 +770,7 @@ async def test_migration_from_v1_with_same_keys( unique_id=None, ) ], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 # or later: Correct device registry, TTS subentry created diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 58685f5cf8f4..e0dad7c41b70 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -259,6 +259,7 @@ dict({ 'device': dict({ 'area_id': None, + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index f2618a385a4e..f0736fad5ae9 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -173,18 +173,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, history_stats_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test config entry is removed when the source entity is removed.""" assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() @@ -196,15 +188,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.history_stats.async_unload_entry", wraps=history_stats.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -212,8 +201,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_history_stats") - # Check that the history_stats config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is removed @@ -383,7 +373,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes history_stats config entry from device.""" + """Test migration from v1.1 keeps the history_stats entity linked to the source device.""" history_stats_config_entry = MockConfigEntry( data={}, @@ -402,21 +392,12 @@ async def test_migration_1_1( ) history_stats_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() assert history_stats_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index ac24876413d7..1d18a527e0c9 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -196,7 +196,10 @@ async def test_remove_stale_device( assert len(device_entries) == 2 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries) - assert any( + # Identifiers are unique per config entry, so Honeywell and OtherDomain have + # separate devices for 7654321; Honeywell's devices do not carry the OtherDomain + # identifier + assert not any( ("OtherDomain", 7654321) in device.identifiers for device in device_entries ) assert len(device_entries_other) == 1 diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 2bc95fad38d9..5b6ea05f7464 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -266,18 +266,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, integration_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() @@ -289,15 +281,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.integration.async_unload_entry", wraps=integration.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -306,6 +295,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d integration_entity_entry = entity_registry.async_get("sensor.my_integration") assert integration_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -471,7 +463,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes integration config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" integration_config_entry = MockConfigEntry( data={}, @@ -491,22 +483,13 @@ async def test_migration_1_1( ) integration_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=integration_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() assert integration_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries integration_entity_entry = entity_registry.async_get("sensor.my_integration") diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 7664a1b9bdc1..c5cb4abb6660 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -274,16 +274,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d expected_helper_device_id: str | None, expected_events: list[str], ) -> None: - """Test config entry removed when the source entity is removed.""" + """Test the source entity is removed but the source device is not removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() @@ -297,15 +290,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.mold_indicator.async_unload_entry", wraps=mold_indicator.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -314,6 +304,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") assert mold_indicator_entity_entry.device_id == expected_helper_device_id + # Check that the source device is not removed + assert device_registry.async_get(source_device.id) is not None + # Check if the mold_indicator config entry is not in the device source_device = device_registry.async_get(source_device.id) assert mold_indicator_config_entry.entry_id not in source_device.config_entries @@ -533,7 +526,7 @@ async def test_migration_1_1( indoor_temperature_entity_entry: er.RegistryEntry, outdoor_temperature_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes mold_indicator config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" mold_indicator_config_entry = MockConfigEntry( data={}, @@ -551,25 +544,15 @@ async def test_migration_1_1( ) mold_indicator_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - indoor_humidity_device.id, - add_config_entry_id=mold_indicator_config_entry.entry_id, - ) - - # Check preconditions - switch_device = device_registry.async_get(indoor_humidity_device.id) - assert mold_indicator_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() assert mold_indicator_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device - switch_device = device_registry.async_get(switch_device.id) - assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device + source_device = device_registry.async_get(indoor_humidity_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 1d64f4742af1..26abd4018071 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -70,6 +70,21 @@ from tests.typing import ( WebSocketGenerator, ) + +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + TEST_SINGLE_CONFIGS = [ ( "homeassistant/device_automation/0AFFD2/bla1/config", @@ -2047,15 +2062,24 @@ async def test_cleanup_device_multiple_config_entries( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None @@ -2065,7 +2089,7 @@ async def test_cleanup_device_multiple_config_entries( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry.id, mqtt_config_entry.entry_id + mqtt_device_entry.id, mqtt_config_entry.entry_id ) assert response["success"] @@ -2165,15 +2189,24 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 1bf8a425da56..f5f4e52ce488 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -46,6 +46,20 @@ DEFAULT_TAG_SCAN_JSON = ( ) +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.mark.no_fail_on_log_exception async def test_discover_bad_tag( hass: HomeAssistant, @@ -570,24 +584,45 @@ async def test_cleanup_tag( async_fire_mqtt_message(hass, "homeassistant/tag/bla2/config", data2) await hass.async_block_till_done() - # Verify device registry entries are created - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + # Verify device registry entries are created. Identifiers are unique per config + # entry, so the test config entry and MQTT get separate "helloworld" devices + device_entry1 = _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, ) assert device_entry1 is not None - assert device_entry1.config_entries == {config_entry.entry_id, mqtt_entry.entry_id} + assert device_entry1.config_entries == {config_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None - # Remove other config entry from the device + # Removing the test config entry deletes its device; the MQTT device is untouched + # and MQTT does not clear its discovery topic device_registry.async_update_device( device_entry1.id, remove_config_entry_id=config_entry.entry_id ) - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + is None ) - assert device_entry1 is not None - assert device_entry1.config_entries == {mqtt_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None mqtt_mock.async_publish.assert_not_called() @@ -595,7 +630,7 @@ async def test_cleanup_tag( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry1.id, mqtt_config_entry.entry_id + mqtt_device_entry1.id, mqtt_config_entry.entry_id ) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index d16d4fd4c0b4..340d1dcb7249 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -732,7 +732,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index f8d85e353e74..73dd2a79c5f4 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1278,7 +1278,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 2324b01ab02a..cda4479f3bf6 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -200,31 +200,6 @@ async def test_service_set_kvs_value( mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value") -async def test_service_get_kvs_value_config_entry_not_found( - hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry -) -> None: - """Test device with no config entries.""" - entry = await init_integration(hass, 2) - - device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - - # Remove all config entries from device - device_registry.devices[device.id].config_entries.clear() - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, - blocking=True, - return_response=True, - ) - - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device.id} - - async def test_service_get_kvs_value_device_not_initialized( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/snooz/snapshots/test_init.ambr b/tests/components/snooz/snapshots/test_init.ambr index ef893776b22d..79c37a923a35 100644 --- a/tests/components/snooz/snapshots/test_init.ambr +++ b/tests/components/snooz/snapshots/test_init.ambr @@ -28,7 +28,7 @@ 'model_id': None, 'name': None, 'name_by_user': None, - 'primary_config_entry': None, + 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index 7dca15875689..3901f464d219 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -158,18 +158,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, statistics_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the statistics config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() @@ -181,15 +173,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.statistics.async_unload_entry", wraps=statistics.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -197,6 +186,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_statistics") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +354,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes statistics config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" statistics_config_entry = MockConfigEntry( data={}, @@ -382,22 +374,13 @@ async def test_migration_1_1( ) statistics_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() assert statistics_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 6aed898fadf9..f3cddd346f03 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -208,12 +208,6 @@ async def test_device_registry_config_entry_1( device_id=device_entry.id, original_name="ABC", ) - # Add another config entry to the same device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=other_config_entry.entry_id - ) switch_as_x_config_entry = MockConfigEntry( data={}, @@ -246,15 +240,12 @@ async def test_device_registry_config_entry_1( async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) - # Remove the wrapped switch's config entry from the device, this removes the - # wrapped switch + # Remove the wrapped switch, this removes the switch_as_x config entry with patch( "homeassistant.components.switch_as_x.async_unload_entry", wraps=switch_as_x.async_unload_entry, ) as mock_setup_entry: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=switch_config_entry.entry_id - ) + entity_registry.async_remove(switch_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -1134,9 +1125,6 @@ async def test_migrate( minor_version=1, ) config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=config_entry.entry_id - ) switch_as_x_entity_entry = entity_registry.async_get_or_create( target_domain, "switch_as_x", @@ -1179,19 +1167,9 @@ async def test_migrate( assert hass.states.get(f"{target_domain}.abc") is not None assert entity_registry.async_get(f"{target_domain}.abc") is not None - # Entity removed from device to prevent deletion, then added back to device - assert events == [ - { - "action": "update", - "changes": {"device_id": device_entry.id}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - { - "action": "update", - "changes": {"device_id": None}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - ] + # The switch_as_x config entry was never added to the device, so migration does + # not change the switch_as_x entity's device link + assert events == [] @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 1c987f7466c3..77a231826a26 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -23,6 +23,20 @@ from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient, WebSocketGenerator +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -324,12 +338,21 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + tasmota_device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) - assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert tasmota_device_entry is not None + assert tasmota_device_entry.config_entries == {tasmota_entry.entry_id} + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + assert mock_device_entry is not None async_fire_mqtt_message( hass, @@ -338,9 +361,19 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is not removed - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify the Tasmota device is removed, but the other config entry's device is not + assert ( + _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + is None + ) + device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None assert device_entry.config_entries == {mock_entry.entry_id} @@ -378,21 +411,29 @@ async def test_device_remove_multiple_config_entries_2( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert device_entry.config_entries == {tasmota_entry.entry_id} assert other_device_entry.id != device_entry.id - # Remove other config entry from the device + # Remove the config entry from the other (non-Tasmota) device sharing the connection + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=mock_entry.entry_id + mock_device_entry.id, remove_config_entry_id=mock_entry.entry_id ) await hass.async_block_till_done() - # Verify device entry is not removed + # Verify the Tasmota device entry is not removed device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) diff --git a/tests/components/telegram_bot/test_init.py b/tests/components/telegram_bot/test_init.py index 7c3bfb6cacf0..749bc10ebc19 100644 --- a/tests/components/telegram_bot/test_init.py +++ b/tests/components/telegram_bot/test_init.py @@ -74,6 +74,7 @@ async def test_migrate_entry_from_1_1( } +@pytest.mark.parametrize("collapsed_chat_index", [0, 1]) @pytest.mark.parametrize( "chats_without_notify_entity", [ @@ -86,9 +87,10 @@ async def test_migrate_entry_to_per_chat_devices( mock_external_calls: None, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + collapsed_chat_index: int, chats_without_notify_entity: tuple[int, ...], ) -> None: - """Test migrating a shared bot device to per-chat devices.""" + """Test migrating chats sharing one bot device to per-chat devices.""" bot_id = 123456 # test_user id from mock_external_calls chat_ids = (123456, 654321) config_entry = MockConfigEntry( @@ -119,22 +121,13 @@ async def test_migrate_entry_to_per_chat_devices( config_entry.add_to_hass(hass) subentry_ids = list(config_entry.subentries) - # Pre-migration state: one shared bot device associated with the config entry (None) - # and every chat subentry, holding the event entity and every chat's notify entity. + # Post-store-migration state: one shared bot device collapsed onto an arbitrary chat + # subentry, holding the event entity and every surviving chat's notify entity. bot_device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_ids[collapsed_chat_index], identifiers={(DOMAIN, str(bot_id))}, ) - for subentry_id in subentry_ids: - bot_device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - config_subentry_id=subentry_id, - identifiers={(DOMAIN, str(bot_id))}, - ) - assert bot_device.config_entries_subentries == { - config_entry.entry_id: {None, *subentry_ids} - } - event_entity = entity_registry.async_get_or_create( "event", DOMAIN, @@ -161,33 +154,26 @@ async def test_migrate_entry_to_per_chat_devices( assert config_entry.state is ConfigEntryState.LOADED assert config_entry.minor_version == 3 - # Each chat has its own device, owned by that chat's subentry and linked to the bot - # device. - chat_devices = { - chat_id: device_registry.async_get_device( + # Every chat has its own device - owned by that subentry and linked to the bot device - + # even a chat whose notify entity was deleted before the migration ran. A surviving + # notify entity is moved onto its chat's device. + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): + chat_device = device_registry.async_get_device( identifiers={(DOMAIN, f"{bot_id}_{chat_id}")} ) - for chat_id in chat_ids - } - for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): - chat_device = chat_devices[chat_id] assert chat_device is not None - assert chat_device.config_entries_subentries == { - config_entry.entry_id: {subentry_id} - } + assert chat_device.config_subentry_id == subentry_id assert chat_device.via_device_id == bot_device.id + if chat_id in notify_entities: + assert ( + entity_registry.async_get(notify_entities[chat_id].entity_id).device_id + == chat_device.id + ) - # Every notify entity that survived is moved onto its chat's device - for chat_id, notify_entity in notify_entities.items(): - assert ( - entity_registry.async_get(notify_entity.entity_id).device_id - == chat_devices[chat_id].id - ) - - # The bot device ends up associated with only (entry, None), keeping the event entity + # The bot device was handed back to the config entry, keeping the event entity bot_device = device_registry.async_get(bot_device.id) assert bot_device is not None - assert bot_device.config_entries_subentries == {config_entry.entry_id: {None}} + assert bot_device.config_subentry_id is None assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id @@ -203,34 +189,23 @@ async def test_per_chat_devices( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() - entry_id = mock_broadcast_config_entry.entry_id - # The bot device belongs to the config entry (no subentry) and holds the event entity bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) assert bot_device is not None - assert bot_device.config_entries_subentries == {entry_id: {None}} - assert bot_device.name == "Mock Title" + assert bot_device.config_subentry_id is None - for chat_id, chat_name in ((123456, "mock chat 1"), (654321, "mock chat 2")): - subentry_id = next( - sid - for sid, subentry in mock_broadcast_config_entry.subentries.items() - if subentry.data[CONF_CHAT_ID] == chat_id - ) + for chat_id in (123456, 654321): chat_device = device_registry.async_get_device( identifiers={(DOMAIN, f"123456_{chat_id}")} ) assert chat_device is not None - assert chat_device.config_entries_subentries == {entry_id: {subentry_id}} + assert chat_device.config_subentry_id is not None assert chat_device.via_device_id == bot_device.id - # The device is named after the chat, and its notify entity takes the device name - assert chat_device.name == chat_name notify_entity_id = entity_registry.async_get_entity_id( "notify", DOMAIN, f"123456_{chat_id}" ) assert notify_entity_id is not None assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id - assert hass.states.get(notify_entity_id).name == chat_name async def test_remove_chat_subentry_removes_per_chat_device( diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 053c81280ba7..edd85ec0dad0 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -532,7 +532,7 @@ async def test_migration_1_1( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from v1.1 removes template config entry from device.""" + """Test migration from v1.1 does not add the template config entry to the device.""" device_config_entry = MockConfigEntry() device_config_entry.add_to_hass(hass) @@ -557,21 +557,12 @@ async def test_migration_1_1( ) template_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - device_entry.id, add_config_entry_id=template_config_entry.entry_id - ) - - # Check preconditions - device_entry = device_registry.async_get(device_entry.id) - assert template_config_entry.entry_id in device_entry.config_entries - await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() assert template_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # entity is linked to the source device device_entry = device_registry.async_get(device_entry.id) assert template_config_entry.entry_id not in device_entry.config_entries diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 0f92a0c0e68e..92bbb62fcd95 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -265,18 +265,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, threshold_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the threshold config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() @@ -288,15 +280,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.threshold.async_unload_entry", wraps=threshold.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -305,6 +294,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") assert threshold_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -470,7 +462,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes threshold config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" threshold_config_entry = MockConfigEntry( data={}, @@ -488,22 +480,13 @@ async def test_migration_1_1( ) threshold_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() assert threshold_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") diff --git a/tests/components/todo/test_trigger.py b/tests/components/todo/test_trigger.py index b3af229f6948..e63493f1bb8b 100644 --- a/tests/components/todo/test_trigger.py +++ b/tests/components/todo/test_trigger.py @@ -93,8 +93,12 @@ def target_todo_lists( label_list_one = label_registry.async_create("label_list_one") label_list_two = label_registry.async_create("label_list_two") - device_list_one = dr.DeviceEntry(id="device_list_one") - device_list_two = dr.DeviceEntry(id="device_list_two") + device_list_one = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_one" + ) + device_list_two = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_two" + ) mock_device_registry( hass, { diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 689074c463fa..c6f9a783ef97 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -190,18 +190,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, trend_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the trend config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() @@ -213,15 +205,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.trend.async_unload_entry", wraps=trend.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -229,6 +218,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("binary_sensor.my_trend") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -394,7 +386,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes trend config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" trend_config_entry = MockConfigEntry( data={}, @@ -410,22 +402,13 @@ async def test_migration_1_1( ) trend_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=trend_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() assert trend_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 0cfc54fa3a2c..800f64359d70 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -651,19 +651,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, utility_meter_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], ) -> None: - """Test config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed while the source device survives.""" assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() @@ -682,15 +674,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.utility_meter.async_unload_entry", wraps=utility_meter.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -703,8 +692,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d ): assert utility_meter_entity.device_id is None - # Check that the utility_meter config entry is not in the device + # Check that the source device survives and does not contain the utility_meter + # config entry sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed @@ -962,7 +953,7 @@ async def test_migration_2_1( tariffs: list[str], expected_entities: set[str], ) -> None: - """Test migration from v2.1 removes utility_meter config entry from device.""" + """Test migration from v2.1 does not add the utility_meter config entry to the device.""" utility_meter_config_entry = MockConfigEntry( data={}, @@ -983,25 +974,15 @@ async def test_migration_2_1( ) utility_meter_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() assert utility_meter_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # entities are linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Check that the entities are linked to the other device entities = set() for ( utility_meter_entity diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py index a5c85b57e7a4..7cb642aa9b75 100644 --- a/tests/components/waqi/test_init.py +++ b/tests/components/waqi/test_init.py @@ -201,6 +201,10 @@ async def test_migration_from_v1( "sensor_entity_id": ( "sensor.not_de_jongweg_utrecht_air_quality_index" ), + # Device 2 was created enabled; the migration moves it onto the + # disabled merged config entry, so the move re-evaluates it as disabled + # by CONFIG_ENTRY (the entity keeps its own disabled_by - propagating a + # move-disable to entities is a separate mechanism) "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, "entity_disabled_by": None, "device": 1, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c87f83a0f273..2fecb7157b0f 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -127,15 +127,30 @@ async def target_entities( area_registry.async_update(label_area.id, labels={label1.label_id}) - device1 = dr.DeviceEntry(id="device1", identifiers={("test", "device1")}) - device2 = dr.DeviceEntry(id="device2", identifiers={("test", "device2")}) + device1 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device1", + identifiers={("test", "device1")}, + ) + device2 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device2", + identifiers={("test", "device2")}, + ) area_device = dr.DeviceEntry( - id="area_device", identifiers={("test", "device3")}, area_id=kitchen_area.id + config_entry_id=config_entry.entry_id, + id="area_device", + identifiers={("test", "device3")}, + area_id=kitchen_area.id, ) label2_device = dr.DeviceEntry( - id="label_device", identifiers={("test", "device4")}, labels={label2.label_id} + config_entry_id=config_entry.entry_id, + id="label_device", + identifiers={("test", "device4")}, + labels={label2.label_id}, ) diag_only_device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, id="diag_only_device", identifiers={("test", "device5")}, area_id=garage_area.id, diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index c07f001c8e3a..0a44756f8f4a 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -449,3 +449,58 @@ async def test_device_two_config_entries( await hass.async_block_till_done() assert "Platform withings does not generate unique IDs" not in caplog.text + + +async def test_old_device_removal_only_removes_own_device( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + second_polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Removing an old device only removes the processing entry's own device. + + Two config entries can each own a device registry entry for the same shared sub-device. + When the sub-device disappears from one entry, it must remove its own device, not + another entry's device sharing the identifier. + """ + identifiers = {(DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d")} + + def _device_for_entry(entry: MockConfigEntry) -> dr.DeviceEntry | None: + return next( + ( + device + for device in device_registry.devices.get_entries( + identifiers=identifiers + ) + if device.config_entry_id == entry.entry_id + ), + None, + ) + + # The first entry creates the sub-device and owns its device registry entry. + await setup_integration(hass, polling_config_entry, False) + assert _device_for_entry(polling_config_entry) is not None + + # Unload it, then set up a second entry: with the first entry unloaded it no longer + # provides the sub-device, so the second entry creates and owns its own device. + await hass.config_entries.async_unload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + second_polling_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(second_polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert _device_for_entry(polling_config_entry) is not None + assert _device_for_entry(second_polling_config_entry) is not None + + # The sub-device disappears from the (still loaded) second entry's data. + withings.get_devices.return_value = [] + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Only the second entry's own device was removed; the first entry's remains. + assert _device_for_entry(second_polling_config_entry) is None + assert _device_for_entry(polling_config_entry) is not None diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index 445411eb6fc7..7576967bdf1a 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -233,8 +233,9 @@ async def test_migration_merges_duplicate_v1_entries( wolf_mock.return_value.fetch_system_list.side_effect = RequestError( "Unable to connect" ) + # Setting up the first entry loads the integration, which sets up and migrates + # every wolflink entry: the first becomes the hub and the second merges into it. await hass.config_entries.async_setup(first_entry.entry_id) - await second_entry.async_migrate(hass) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 33da980715fd..cbe16456a8ac 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,9 +1,11 @@ """Tests for the Device Registry.""" -from collections.abc import Iterable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager, nullcontext from datetime import datetime from functools import partial +import json +import pathlib import time from typing import Any from unittest.mock import ANY, patch @@ -28,6 +30,20 @@ from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_capture_events, flush_store +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a mock config entry and add it to hass.""" @@ -160,10 +176,27 @@ async def test_requirement_for_identifier_or_connection( ) +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: + """Test async_get raises when the registry has not been set up.""" + with pytest.raises(RuntimeError, match="Device registry not set up"): + dr.async_get(hass) + + dr.async_setup(hass) + assert isinstance(dr.async_get(hass), dr.DeviceRegistry) + + +async def test_async_load_twice_raises(hass: HomeAssistant) -> None: + """Test loading the device registry twice raises.""" + registry = dr.async_get(hass) + with pytest.raises(RuntimeError, match="Device registry is already loaded"): + await registry.async_load() + + async def test_multiple_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" + """Test registering a device for multiple config entries with same identifiers.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -191,133 +224,70 @@ async def test_multiple_config_entries( model="model", ) - assert len(device_registry.devices) == 1 - assert entry.id == entry2.id + # Identifiers and connections are unique per config entry: the two config entries + # get separate devices, while re-registering for the first entry reuses its device + assert len(device_registry.devices) == 2 + assert entry.id != entry2.id assert entry.id == entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.primary_config_entry == config_entry_1.entry_id - assert entry3.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry3.primary_config_entry == config_entry_1.entry_id + assert entry.config_entry_id == config_entry_1.entry_id + assert entry2.config_entry_id == config_entry_2.entry_id async def test_multiple_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - config_entry_1 = MockConfigEntry( + """Test re-registering a device under different subentries of one config entry.""" + config_entry = MockConfigEntry( subentries_data=( config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), ) ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} - entry_id = entry.id - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=None, + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - } - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - -@pytest.mark.parametrize("load_registries", [False]) -async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: - """Test async_get raises when the registry has not been set up.""" - with pytest.raises(RuntimeError, match="Device registry not set up"): - dr.async_get(hass) - - dr.async_setup(hass) - assert isinstance(dr.async_get(hass), dr.DeviceRegistry) - - -async def test_async_load_twice_raises(hass: HomeAssistant) -> None: - """Test loading the device registry twice raises.""" - registry = dr.async_get(hass) - with pytest.raises(RuntimeError, match="Device registry is already loaded"): - await registry.async_load() + # A device belongs to a single subentry; re-registering the same identifiers under + # another subentry of the same config entry moves the device rather than duplicating + assert len(device_registry.devices) == 1 + assert entry.id == entry2.id == entry3.id + assert entry2.config_subentry_id == "mock-subentry-id-2" + assert entry3.config_subentry_id == "mock-subentry-id-1" @pytest.mark.parametrize("load_registries", [False]) @@ -339,6 +309,12 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": created_at, @@ -365,6 +341,9 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "has_composite_identifiers": False, "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "disabled_by": dr.DeviceEntryDisabler.USER, @@ -375,6 +354,7 @@ async def test_loading_from_storage( "modified_at": modified_at, "name_by_user": "Test Friendly Name", "orphaned_timestamp": None, + "domain": None, } ], }, @@ -388,8 +368,8 @@ async def test_loading_from_storage( assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -410,8 +390,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, created_at=datetime.fromisoformat(created_at), @@ -427,7 +407,6 @@ async def test_loading_from_storage( modified_at=datetime.fromisoformat(modified_at), name_by_user="Test Friendly Name", name="name", - primary_config_entry=mock_config_entry.entry_id, serial_number="serial_no", sw_version="version", ) @@ -445,8 +424,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -457,7 +436,6 @@ async def test_loading_from_storage( model="model", modified_at=utcnow(), name_by_user="Test Friendly Name", - primary_config_entry=mock_config_entry.entry_id, ) assert entry.id == "bcdefghijklmn" assert isinstance(entry.config_entries, set) @@ -552,8 +530,12 @@ async def test_migration_from_1_1( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -576,8 +558,12 @@ async def test_migration_from_1_1( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -593,7 +579,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -602,8 +588,8 @@ async def test_migration_from_1_1( "deleted_devices": [ { "area_id": None, - "config_entries": ["123456"], - "config_entries_subentries": {"123456": [None]}, + "config_entry_id": "123456", + "config_subentry_id": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -614,6 +600,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "orphaned_timestamp": None, + "domain": None, } ], }, @@ -705,8 +692,12 @@ async def test_migration_from_1_2( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -729,8 +720,12 @@ async def test_migration_from_1_2( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -746,7 +741,7 @@ async def test_migration_from_1_2( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -842,8 +837,12 @@ async def test_migration_fom_1_3( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -866,8 +865,12 @@ async def test_migration_fom_1_3( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -883,7 +886,7 @@ async def test_migration_fom_1_3( "modified_at": "1970-01-01T00:00:00+00:00", "name": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -923,7 +926,7 @@ async def test_migration_from_1_4( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -981,8 +984,12 @@ async def test_migration_from_1_4( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1005,8 +1012,12 @@ async def test_migration_from_1_4( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1022,7 +1033,7 @@ async def test_migration_from_1_4( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1063,7 +1074,7 @@ async def test_migration_from_1_5( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -1122,8 +1133,12 @@ async def test_migration_from_1_5( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1146,8 +1161,12 @@ async def test_migration_from_1_5( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1163,7 +1182,7 @@ async def test_migration_from_1_5( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1222,7 +1241,7 @@ async def test_migration_from_1_6( "manufacturer": None, "model": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1265,8 +1284,12 @@ async def test_migration_from_1_6( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1289,8 +1312,12 @@ async def test_migration_from_1_6( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1306,7 +1333,7 @@ async def test_migration_from_1_6( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1367,7 +1394,7 @@ async def test_migration_from_1_7( "model": None, "model_id": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1410,8 +1437,12 @@ async def test_migration_from_1_7( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1434,8 +1465,12 @@ async def test_migration_from_1_7( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1451,7 +1486,7 @@ async def test_migration_from_1_7( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1556,8 +1591,12 @@ async def test_migration_from_1_10( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1582,8 +1621,9 @@ async def test_migration_from_1_10( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1693,8 +1733,12 @@ async def test_migration_from_1_11( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1719,8 +1763,9 @@ async def test_migration_from_1_11( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1737,11 +1782,1472 @@ async def test_migration_from_1_11( } +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_12( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 1.12. + + Version 3.1 restricts a device to a single config entry and subentry: a device + belonging to several config entries is split into one device per config entry (each + keeping a copy of the identifiers/connections and a legacy reference to the composite + id), while a device in several subentries of one config entry is collapsed onto a + single subentry (preferring a real subentry over the main entry). A device already + tied to a single config entry and subentry keeps its id. + """ + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + config_entry_3.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite device belonging to two config entries -> split in two + { + "area_id": "area_1", + "config_entries": [ + mock_config_entry.entry_id, + config_entry_2.entry_id, + ], + "config_entries_subentries": { + mock_config_entry.entry_id: [None], + config_entry_2.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "composite0000000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + }, + # Composite device spanning several subentries of one config entry -> + # split into one device per subentry (including the no-subentry one) + { + "area_id": None, + "config_entries": [config_entry_3.entry_id], + "config_entries_subentries": { + config_entry_3.entry_id: [ + None, + "mock-subentry-id-1", + "mock-subentry-id-2", + ] + }, + "configuration_url": None, + "connections": [["mac", "34:56:78:cd:ef:12"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "subentries00000000000000000000", + "identifiers": [["domain_c", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": config_entry_3.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Single (config entry, subentry) device -> keeps its id, no legacy ref + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "singleentry00000000000000000000", + "identifiers": [["domain_a", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The single (config entry, subentry) device keeps its id and has no legacy reference + single = registry.async_get("singleentry00000000000000000000") + assert single is not None + assert single.config_entry_id == mock_config_entry.entry_id + assert single.config_subentry_id is None + assert single.composite_device_id is None + assert single.has_composite_identifiers is False + + # The composite spanning two config entries is split into one device per config entry + assert "composite0000000000000000000000" not in registry.devices + entry_splits = registry.async_get_devices_for_composite_device_id( + "composite0000000000000000000000" + ) + assert len(entry_splits) == 2 + assert {(d.config_entry_id, d.config_subentry_id) for d in entry_splits} == { + (mock_config_entry.entry_id, None), + (config_entry_2.entry_id, None), + } + for device in entry_splits: + assert device.id != "composite0000000000000000000000" + # Each split copies the identity and customizations of the composite ... + assert device.identifiers == {("domain_a", "1"), ("domain_b", "1")} + assert device.connections == {("mac", "12:34:56:ab:cd:ef")} + assert device.area_id == "area_1" + assert device.name_by_user == "custom name" + assert device.labels == {"lab"} + assert device.serial_number == "SERIAL" + # ... and records its composite_device_id, keeping the copied identifiers + assert device.composite_device_id == "composite0000000000000000000000" + assert device.composite_primary_config_entry == mock_config_entry.entry_id + assert device.split_at is not None + assert device.has_composite_identifiers is True + + # A device spanning several subentries of ONE config entry is an invalid state (only + # a buggy 2025.7 subentry migration produced it); it is collapsed to a single device + # on one subentry - preferring a real subentry over the main entry (None) - rather + # than split into duplicate devices sharing the same identifiers/connections. It + # keeps its id and gains no composite bookkeeping. + assert "subentries00000000000000000000" in registry.devices + assert ( + registry.async_get_devices_for_composite_device_id( + "subentries00000000000000000000" + ) + == [] + ) + collapsed = _get_device_for_config_entry( + registry, config_entry_3.entry_id, identifiers={("domain_c", "1")} + ) + assert collapsed is not None + assert collapsed.id == "subentries00000000000000000000" + assert collapsed.config_entry_id == config_entry_3.entry_id + assert collapsed.config_subentry_id == "mock-subentry-id-1" + assert collapsed.identifiers == {("domain_c", "1")} + assert collapsed.connections == {("mac", "34:56:78:cd:ef:12")} + assert collapsed.composite_device_id is None + assert collapsed.has_composite_identifiers is False + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_backs_up_store_file( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_tmp_config_dir: str, +) -> None: + """The store file is copied to a timestamped backup before the version 3 migration.""" + hass.config.config_dir = hass_tmp_config_dir + storage_dir = pathlib.Path(hass_tmp_config_dir) / ".storage" + storage_dir.mkdir(parents=True, exist_ok=True) + old_store = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": {"devices": [], "deleted_devices": []}, + } + (storage_dir / dr.STORAGE_KEY).write_text(json.dumps(old_store)) + hass_storage[dr.STORAGE_KEY] = old_store + + dr.async_setup(hass) + await dr.async_load(hass) + + # Exactly one timestamped copy of the pre-migration file was made + backups = list(storage_dir.glob(f"{dr.STORAGE_KEY}.*.migration_backup")) + assert len(backups) == 1 + assert json.loads(backups[0].read_text()) == old_store + # The middle segment is a YYYYMMDD_HHMMSS timestamp (strptime raises if malformed) + datetime.strptime(backups[0].name.split(".")[-2], "%Y%m%d_%H%M%S") + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_detaches_via_device_of_dropped_parent( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A child of an ownerless parent dropped by the migration has its link detached. + + The migration drops an active device with no config entry; normally + async_remove_device would clear via_device_id links to it, so the migration must too. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) + + def _device(**overrides: Any) -> dict[str, Any]: + device = { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device0000000000000000000000000", + "identifiers": [["test", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + return device | overrides + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Ownerless parent (no config entries) -> dropped by the migration + _device( + id="orphan0000000000000000000000000", + config_entries=[], + config_entries_subentries={}, + identifiers=[["test", "orphan"]], + primary_config_entry=None, + ), + # Child linked to the orphan via via_device_id + _device( + id="child00000000000000000000000000", + identifiers=[["test", "child"]], + via_device_id="orphan0000000000000000000000000", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The ownerless parent was dropped; the child survives with its link detached + assert registry.async_get("orphan0000000000000000000000000") is None + child = registry.async_get("child00000000000000000000000000") + assert child is not None + assert child.via_device_id is None + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_collapses_multi_subentry_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device wrongly assigned to several subentries of one config entry collapses. + + Only a buggy 2025.7 subentry migration produced this state. The migration must + collapse it to a single device (preferring a real subentry over the main entry, + None), NOT split it into duplicate devices sharing the same identifiers/connections. + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Sub 1", + unique_id="s1", + ), + ] + ) + entry.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None, "sub-1"]}, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "buggydevice00000000000000000", + "identifiers": [["test", "device-1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Collapsed to a single device (no duplicate), on the real subentry, keeping its id + assert len(registry.devices) == 1 + device = registry.async_get("buggydevice00000000000000000") + assert device is not None + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id == "sub-1" + assert device.config_entries_subentries == {entry.entry_id: {"sub-1"}} + # It is not split and stays findable by identifier and connection (not shadowed) + assert ( + registry.async_get_devices_for_composite_device_id( + "buggydevice00000000000000000" + ) + == [] + ) + assert device.composite_device_id is None + assert device.has_composite_identifiers is False + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, identifiers={("test", "device-1")} + ) + is device + ) + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, connections={("mac", "12:34:56:ab:cd:ef")} + ) + is device + ) + + +async def test_async_get_or_create_moves_device_between_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Re-registering under a different subentry moves the device, not duplicates it. + + Identifiers and connections are unique per config entry (not per subentry), so a + second async_get_or_create with the same identifier/connection but a different + subentry of the same config entry moves the existing device - it neither creates a + duplicate nor raises. + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="1", + unique_id="s1", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-2", + subentry_type="test", + title="2", + unique_id="s2", + ), + ] + ) + entry.add_to_hass(hass) + + # Same identifier, different subentry -> the existing device is moved + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + identifiers={("test", "1")}, + ) + assert device.config_subentry_id == "sub-1" + moved = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + identifiers={("test", "1")}, + ) + assert moved.id == device.id + assert moved.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 1 + + # Same connection, different subentry -> also moved, not duplicated + device_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + connections={("mac", "12:34:56:ab:cd:ef")}, + ) + moved_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + connections={("mac", "12:34:56:ab:cd:ef")}, + ) + assert moved_2.id == device_2.id + assert moved_2.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 2 + + +async def test_async_get_device_returns_first_match_for_ambiguous_lookup( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Independent devices sharing an identifier resolve to the first match. + + They are not splits of one pre-migration composite (no shared composite_device_id), + so there is nothing to merge and the lookup returns one of the real devices rather + than a composite. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + assert device_1.id != device_2.id + + match = device_registry.async_get_device(identifiers={("test", "shared")}) + # A real registry device (the first match), not a synthesized composite + assert match is device_1 + assert match.id in device_registry.devices + assert match.config_entries == {entry_1.entry_id} + + +async def test_async_get_device_prefers_calling_integration( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An ambiguous lookup prefers a device owned by the calling integration.""" + entry_a = MockConfigEntry(domain="itg_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="itg_b") + entry_b.add_to_hass(hass) + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + # itg_a's device is indexed first (created first) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, connections={mac} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, connections={mac} + ) + assert device_a.id != device_b.id + + # Each integration resolves to its own device, regardless of index order + with patch.object(dr, "_current_integration_domain", return_value="itg_b"): + assert device_registry.async_get_device(connections={mac}) is device_b + with patch.object(dr, "_current_integration_domain", return_value="itg_a"): + assert device_registry.async_get_device(connections={mac}) is device_a + + # A caller owning neither, or no integration frame, falls back to the first match + with patch.object(dr, "_current_integration_domain", return_value="other"): + assert device_registry.async_get_device(connections={mac}) is device_a + with patch.object(dr, "_current_integration_domain", return_value=None): + assert device_registry.async_get_device(connections={mac}) is device_a + + +async def test_async_get_device_prefers_matching_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A lookup prefers the device whose config entry domain matches the identifier. + + Right after the migration split, and until identifiers are pruned, every split still + carries the composite's full identifier set, so a lookup matches all splits; the + domain match resolves it to the correct single device without a composite. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + # entry_b's device also carries domain_a's identifier (unpruned split state) + device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, + identifiers={("domain_a", "1"), ("domain_b", "2")}, + ) + assert device_registry.async_get_device(identifiers={("domain_a", "1")}) is device_a + + +async def test_async_remove_device_fans_out_to_migration_composite( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """async_remove_device on a pre-migration composite id removes its splits.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + device_registry.async_remove_device(old_id) + + assert device_1.id not in device_registry.devices + assert device_2.id not in device_registry.devices + + +async def test_async_update_device_fans_out_to_migration_composite( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """async_update_device on a pre-migration composite id fans out to its splits.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + device_registry.async_update_device(old_id, name_by_user="merged") + + assert device_registry.async_get(device_1.id).name_by_user == "merged" + assert device_registry.async_get(device_2.id).name_by_user == "merged" + + +async def test_get_entry_by_connection_without_config_entry_scope( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The container resolves by connection when no config entry scope is given.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, connections={connection} + ) + assert device_registry.devices.get_entry(connections={connection}) is device + + +async def test_update_unknown_device_id_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Updating an id that is neither a real device nor a composite raises.""" + with pytest.raises(KeyError): + device_registry.async_update_device("unknown0000000000000000000000ab", name="x") + + +async def test_cleanup_removes_device_referencing_missing_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Cleanup drops a device still referencing a config entry that no longer exists.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} + ) + # An entity keeps the device out of the plain-orphan sweep so the defensive + # missing-config-entry path is reached + entity_registry.async_get_or_create("sensor", "test", "unique", device_id=device.id) + + # The device's config entry is no longer known to hass + with patch.object(hass.config_entries, "async_entry_ids", return_value=[]): + dr.async_cleanup(hass, device_registry, entity_registry) + + assert device.id not in device_registry.devices + + +async def test_clear_config_entry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry removes its device, ignoring a pending move. + + add_config_entry_id records a transient pending move; tearing down the owning config + entry must remove the device rather than complete that move to the other entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + device_registry.async_clear_config_entry(entry_1.entry_id) + + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + + +async def test_clear_config_entry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry drops a pending move that targets it. + + A device owned by another entry can hold a transient pending move to the entry being + removed; clearing it stops a later completion from moving the device onto the removed + entry instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + # Start a deferred move to entry_2 (add_config_entry_id without the paired remove yet) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + # entry_2 is torn down before the move completes + device_registry.async_clear_config_entry(entry_2.entry_id) + + # Completing the move by removing the owner must delete the device, not move it onto + # the removed entry_2 + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert result is None + assert device.id not in device_registry.devices + + +async def test_move_to_config_entry_clears_target_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device into a config entry clears a matching deleted device it holds. + + A retained-identity move adds no new identifiers/connections, so the deleted device the + target entry kept for the same identity must still be removed - otherwise the active + device and the deleted device share the target entry's per-identity slot. + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "shared")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + assert device_a.id != device_b.id + + # Leave a deleted device owned by entry_b with the shared identity + device_registry.async_remove_device(device_b.id) + assert device_b.id in device_registry.deleted_devices + + # Move device_a into entry_b, retaining its identity + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_b.entry_id + ) + + assert device_registry.async_get(device_a.id).config_entry_id == entry_b.entry_id + # The deleted device entry_b held for the same identity is cleared, not left immortal + assert device_b.id not in device_registry.deleted_devices + + +async def test_get_or_create_via_device_and_via_device_id_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Passing both via_device and via_device_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with pytest.raises(HomeAssistantError, match="not allowed"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "1")}, + via_device=("test", "via"), + via_device_id="via-device-id", + ) + + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 + + +async def test_get_or_create_invalid_subentry_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An unknown config_subentry_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with pytest.raises(HomeAssistantError, match="has no subentry"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="does-not-exist", + identifiers={("test", "1")}, + ) + + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 + + +async def test_add_current_config_entry_is_noop( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Adding the device's current owner records no pending move. + + So a later removal of that sole owner deletes the device instead of moving it to + itself. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} + ) + + device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id) + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + assert result is None + assert device.id not in device_registry.devices + + +@pytest.mark.parametrize( + "clear_domain", + ["light", None], + ids=["explicit-domain", "auto-resolved-domain"], +) +async def test_reregister_restores_orphan( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + clear_domain: str | None, +) -> None: + """Re-adding an integration restores its orphan. + + async_clear_config_entry records the config entry's domain - passed in by the core + removal flow, or resolved from the still-present entry when omitted - and a later + async_get_or_create under the same domain restores that orphan (id, labels, name) + rather than create a fresh device. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("light", "1")}, name="Original" + ) + device_registry.async_update_device( + device.id, name_by_user="Custom", labels={"label1"} + ) + + # Removing the config entry orphans the deleted device (config_entry_id=None) + device_registry.async_clear_config_entry(entry.entry_id, clear_domain) + orphan = device_registry.deleted_devices[device.id] + assert orphan.config_entry_id is None + assert orphan.domain == "light" + + # Re-add the integration under a new config entry and re-register the device + new_entry = MockConfigEntry(domain="light") + new_entry.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=new_entry.entry_id, identifiers={("light", "1")} + ) + + assert restored.id == device.id + assert restored.config_entry_id == new_entry.entry_id + assert restored.name_by_user == "Custom" + assert restored.labels == {"label1"} + + +async def test_orphan_not_restored_for_other_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan recorded for one integration is not restored by another. + + Identifiers and connections are no longer unique across integrations, so a chance + collision must not restore another integration's orphaned device onto this one. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("light", "1")} + ) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) + assert device_registry.deleted_devices[device.id].domain == "light" + + # A different integration registering a device with the same identifiers gets a fresh + # device, and the orphan is left intact for its own integration to restore later + other_entry = MockConfigEntry(domain="switch") + other_entry.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("light", "1")} + ) + assert fresh.id != device.id + assert device.id in device_registry.deleted_devices + + +async def test_orphaning_replaces_colliding_same_domain_orphan( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Orphaning a device drops a stale same-domain orphan it collides with. + + Two devices from the same integration sharing a connection both orphan under + config_entry_id=None and would collide in the lookup index; the newest orphan replaces + the stale one so a re-add restores it deterministically. + """ + connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")} + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + connections=connections, + identifiers={("hue", "1")}, + ) + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + + device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain) + assert device_1.id in device_registry.deleted_devices + + device_registry.async_clear_config_entry(entry_2.entry_id, entry_2.domain) + # The newer orphan replaces the stale one it collides with on the shared connection + assert device_1.id not in device_registry.deleted_devices + assert device_2.id in device_registry.deleted_devices + + # Re-adding under the same domain restores the surviving orphan + entry_3 = MockConfigEntry(domain="hue") + entry_3.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=entry_3.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + assert restored.id == device_2.id + + +async def test_orphaned_domain_survives_store_round_trip( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan's recorded domain is written to and read back from storage.""" + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} + ) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) + + registry2 = dr.DeviceRegistry(hass) + await flush_store(device_registry._store) + await registry2.async_load() + + assert registry2.deleted_devices[device.id].domain == "hue" + + +async def test_orphan_keeps_domain_when_config_entry_removed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan keeps its domain when its config entry is removed via the normal flow. + + config_entries deletes the entry from the registry before calling + async_clear_config_entry, so async_remove_device can no longer look up the domain and + records None; the domain passed to async_clear_config_entry is what preserves it on + the orphan. Without it the orphan would have domain=None and, with the domain-less + restore fallback gone, could never be restored. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} + ) + + await hass.config_entries.async_remove(entry.entry_id) + + orphan = device_registry.deleted_devices[device.id] + assert orphan.config_entry_id is None + assert orphan.domain == "hue" + + +async def test_cross_domain_orphans_do_not_shadow( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Orphans from different integrations sharing an identifier stay independently found. + + Both orphans would otherwise collide in the config_entry_id=None index; keying orphans + by their recorded domain keeps each restorable by its own integration. + """ + shared = {("test", "shared")} + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers=shared + ) + entry_b = MockConfigEntry(domain="mqtt") + entry_b.add_to_hass(hass) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers=shared + ) + + device_registry.async_clear_config_entry(entry_a.entry_id, entry_a.domain) + device_registry.async_clear_config_entry(entry_b.entry_id, entry_b.domain) + + # Re-adding under each domain restores that domain's own orphan, not the other's + entry_c = MockConfigEntry(domain="mqtt") + entry_c.add_to_hass(hass) + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_c.entry_id, identifiers=shared + ) + assert restored_b.id == device_b.id + + entry_d = MockConfigEntry(domain="hue") + entry_d.add_to_hass(hass) + restored_a = device_registry.async_get_or_create( + config_entry_id=entry_d.entry_id, identifiers=shared + ) + assert restored_a.id == device_a.id + + +async def test_domainless_orphan_not_restored( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A domain-less orphan is not restored; re-registering creates a fresh device. + + The migration carries orphans over without a domain, which can't be resolved once the + config entry is gone. Orphans are matched only on their recorded domain, so a + domain-less one is left for the periodic purge and re-registering makes a new device. + """ + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + + # Simulate an orphan whose domain can no longer be resolved (the migration carries + # orphans over without one) + with patch.object(hass.config_entries, "async_get_entry", return_value=None): + device_registry.async_clear_config_entry(entry_1.entry_id) + assert device_registry.deleted_devices[device_1.id].domain is None + + # Re-registering the shared identifier does not restore the domain-less orphan + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + assert fresh.id != device_1.id + # The un-restored orphan lingers until the periodic purge + assert device_1.id in device_registry.deleted_devices + + +async def test_clear_config_subentry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry removes its device, ignoring a pending move. + + add_config_entry_id records a transient pending move; tearing down the owning + subentry must remove the device rather than complete that move. + """ + entry_1 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("test", "1")}, + ) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + device_registry.async_clear_config_subentry(entry_1.entry_id, "mock-subentry-id-1") + + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + + +async def test_clear_config_subentry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry drops a pending move that targets it. + + A device owned by another entry can hold a transient pending move to the subentry being + removed; clearing it stops a later completion from validating against the removed + subentry (moving the device onto it, or raising) instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + # Start a deferred move into entry_2's subentry + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + + # The target subentry is torn down before the move completes + device_registry.async_clear_config_subentry(entry_2.entry_id, "mock-subentry-id-1") + + # Completing the move by removing the owner must delete the device, not move it onto + # the removed subentry + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert result is None + assert device.id not in device_registry.devices + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_device_composite_reuses_pre_migration_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A composite over migration splits reuses the pre-migration device id. + + Backwards compatibility for unmodified integrations: before the rewrite a shared + connection resolved to one device with a stable id that stored references + (automations, an entity device_id, a fired event device_id) use. The composite over + that device's splits reuses the same id, so those references keep resolving; a + transient id is minted only for a runtime ambiguity between independent devices. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "aa:bb:cc:dd:ee:ff"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "composite00000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # A connections-only lookup matches both splits -> composite reuses the old id + composite = registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + ) + assert composite is not None + assert composite.id == "composite00000000000000000000" + assert composite.id not in registry.devices + # It is the same composite async_get resolves for the old id + assert registry.async_get("composite00000000000000000000").id == composite.id + # An identifier lookup still domain-resolves to the single owning split (real id) + resolved = registry.async_get_device(identifiers={("domain_a", "1")}) + assert resolved.id in registry.devices + assert resolved.config_entry_id == entry_a.entry_id + + +@pytest.mark.parametrize( + "update_kwargs", + [ + pytest.param({"new_identifiers": {("test", "new")}}, id="new_identifiers"), + pytest.param( + {"new_connections": {("mac", "12:34:56:ab:cd:ef")}}, id="new_connections" + ), + pytest.param( + {"merge_identifiers": {("test", "extra")}}, id="merge_identifiers" + ), + pytest.param( + {"merge_connections": {("mac", "12:34:56:ab:cd:ef")}}, + id="merge_connections", + ), + ], +) +async def test_async_update_device_composite_drops_identity_args( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + update_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Identity-rewriting args are ambiguous on a composite: dropped with a warning.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # No raise; the arg is ignored with a report-issue warning, devices untouched + device_registry.async_update_device(old_id, **update_kwargs) + assert "async_entries_for_config_entry" in caplog.text + assert "report this issue" in caplog.text + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_1.id).connections == set() + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} + assert device_registry.async_get(device_2.id).connections == set() + + +async def test_async_update_device_composite_drops_only_disallowed_args( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A composite update applies the allowed args and drops the disallowed ones.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + device_registry.async_update_device( + old_id, + new_identifiers={("test", "renamed")}, # disallowed -> dropped + name_by_user="Custom name", # allowed -> applied to every underlying device + ) + assert "new_identifiers" in caplog.text + # Allowed arg applied to both underlying devices + assert device_registry.async_get(device_1.id).name_by_user == "Custom name" + assert device_registry.async_get(device_2.id).name_by_user == "Custom name" + # Disallowed arg dropped: identities untouched + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} + + +async def test_async_update_device_composite_drops_move_args( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """new_config_entry_id / new_config_subentry_id are dropped on the composite path. + + A forwarded move can't be caught by the identifier/connection checks - the splits have + distinct identities and would move without colliding - so assert each split keeps its + original (config entry, subentry). + """ + entry_1 = MockConfigEntry( + domain="test", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, subentry_type="test", title="Sub", unique_id=None + ) + ], + ) + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + subentry_id = next(iter(entry_1.subentries)) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # Targets are valid, so a forwarded move would land silently - only the owner + # assertions below catch it. + device_registry.async_update_device(old_id, new_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(old_id, new_config_subentry_id=subentry_id) + + assert device_registry.async_get(device_1.id).config_entry_id == entry_1.entry_id + assert device_registry.async_get(device_1.id).config_subentry_id is None + assert device_registry.async_get(device_2.id).config_entry_id == entry_2.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_drops_device_without_config_entries( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """A device with no config entry / subentry pairs is dropped during migration.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Orphan device with no config entries -> dropped + { + "area_id": None, + "config_entries": [], + "config_entries_subentries": {}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "orphan00000000000000000000000", + "identifiers": [["domain_a", "orphan"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": None, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Normal single-config-entry device -> kept + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "keptdevice0000000000000000000", + "identifiers": [["domain_a", "kept"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The orphan device was dropped, the normal device kept + assert registry.async_get("orphan00000000000000000000000") is None + assert "orphan00000000000000000000000" not in registry.devices + kept = registry.async_get("keptdevice0000000000000000000") + assert kept is not None + assert kept.config_entry_id == mock_config_entry.entry_id + assert len(registry.devices) == 1 + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_splits_deleted_device_with_multiple_config_entries( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A deleted device belonging to several config entries is split, one per entry. + + Each split keeps the identity and customizations so every config entry can still + restore its share when a matching device is re-registered. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [], + "deleted_devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, + "id": "deletedcomposite0000000000000", + "identifiers": [["domain_a", "1"]], + "labels": ["lab"], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "orphaned_timestamp": None, + } + ], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Split into one deleted device per config entry, each keeping identity/customizations + assert len(registry.deleted_devices) == 2 + assert "deletedcomposite0000000000000" not in registry.deleted_devices + by_entry = {d.config_entry_id: d for d in registry.deleted_devices.values()} + assert set(by_entry) == {entry_a.entry_id, entry_b.entry_id} + for deleted in by_entry.values(): + assert deleted.identifiers == {("domain_a", "1")} + assert deleted.connections == {("mac", "12:34:56:ab:cd:ef")} + assert deleted.name_by_user == "custom name" + assert deleted.area_id == "area_1" + + # Each config entry can restore its share, with the customizations preserved + restored_a = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert restored_a.config_entry_id == entry_a.entry_id + assert restored_a.name_by_user == "custom name" + + restored_b = registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert restored_b.config_entry_id == entry_b.entry_id + assert restored_b.name_by_user == "custom name" + assert restored_a.id != restored_b.id + + async def test_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + """Test clearing a config entry removes the devices that belong to it.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -1751,86 +3257,36 @@ async def test_removing_config_entries( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry3 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id + # Same identifiers on different config entries are separate devices + assert len(device_registry.devices) == 3 + assert entry.id != entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - } device_registry.async_clear_config_entry(config_entry_1.entry_id) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - entry3_removed = device_registry.async_get_device( - identifiers={("bridgeid", "4567")} - ) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} - assert entry3_removed is None - - await hass.async_block_till_done() - - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } + # Clearing config_entry_1 removes its two devices, leaving config_entry_2's + assert len(device_registry.devices) == 1 + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry3.id) is None + assert device_registry.async_get(entry2.id) is not None async def test_deleted_device_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + """Test clearing a config entry orphans its deleted devices.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -1840,536 +3296,137 @@ async def test_deleted_device_removing_config_entries( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - } - device_registry.async_remove_device(entry.id) - device_registry.async_remove_device(entry3.id) - + device_registry.async_remove_device(entry2.id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 - await hass.async_block_till_done() - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } - device_registry.async_clear_config_entry(config_entry_1.entry_id) - assert len(device_registry.devices) == 0 + + # Deleted devices are kept but orphaned (config entry cleared) so they can be purged assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} + assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert ( + device_registry.deleted_devices[entry2.id].config_entry_id + == config_entry_2.entry_id + ) device_registry.async_clear_config_entry(config_entry_2.entry_id) - assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 - - # Re-add, expect to keep the device id - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - - assert entry.id == entry2.id - - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 - - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() - - # Re-add, expect to get a new device id after the purge - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry3.id != entry4.id + assert device_registry.deleted_devices[entry2.id].config_entry_id is None async def test_removing_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test clearing a config subentry removes the devices that belong to it.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) + assert len(device_registry.devices) == 2 + assert entry.config_subentry_id == "mock-subentry-id-1" + assert entry2.config_subentry_id == "mock-subentry-id-2" + + device_registry.async_clear_config_subentry( + config_entry.entry_id, "mock-subentry-id-1" + ) + + # Only the device on the cleared subentry is removed assert len(device_registry.devices) == 1 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - device_registry.async_update_device( - entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } - - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - assert device_registry.async_get_device(identifiers={("bridgeid", "0123")}) is None - assert device_registry.async_get_device(identifiers={("bridgeid", "4567")}) is None - - await hass.async_block_till_done() - - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[6].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry.id, - "device": entry.dict_repr, - } + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry2.id) is not None async def test_deleted_device_removing_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test clearing a config subentry orphans its deleted devices.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - device_registry.async_remove_device(entry.id) + device_registry.async_remove_device(entry2.id) + assert len(device_registry.deleted_devices) == 2 - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - await hass.async_block_till_done() - - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry.id, - "device": entry4.dict_repr, - } - - device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None - - # Remove the same subentry again device_registry.async_clear_config_subentry( - config_entry_1.entry_id, "mock-subentry-id-1-1" + config_entry.entry_id, "mock-subentry-id-1" ) + + # Only the deleted device on the cleared subentry is orphaned + assert len(device_registry.deleted_devices) == 2 + assert device_registry.deleted_devices[entry.id].config_entry_id is None assert ( - device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) is entry + device_registry.deleted_devices[entry2.id].config_entry_id + == config_entry.entry_id ) - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } - assert entry.orphaned_timestamp is None - - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None - - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 - - # Re-add, expect to keep the device id - hass.config_entries.async_add_subentry( - config_entry_2, - config_entries.ConfigSubentry( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - restored_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert restored_entry.id == entry.id - - # Remove again, and trigger purge - device_registry.async_remove_device(entry.id) - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None - - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 - - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 0 - - # Re-add, expect to get a new device id after the purge - new_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert new_entry.id != entry.id - async def test_removing_area_id( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry @@ -2554,6 +3611,167 @@ async def test_specifying_via_device_update( assert light.name == "New light" +async def test_get_or_create_via_device_and_via_device_id_not_allowed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Passing both via_device and via_device_id is not allowed.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} + ) + + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + via_device_id=via.id, + ) + + # Passing only via_device_id is allowed + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device_id=via.id, + ) + assert device.via_device_id == via.id + + # Passing only the deprecated via_device is still allowed (resolved to via_device_id) + device_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device_2")}, + via_device=("hue", "via"), + ) + assert device_2.via_device_id == via.id + + +async def test_get_or_create_via_device_none( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """`via_device=None` means "no via device"; combining it with via_device_id raises.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} + ) + + # `via_device=None` alongside a via_device_id is contradictory and rejected + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, + via_device_id=via.id, + ) + + # `via_device=None` on its own means no via device + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, + ) + assert device.via_device_id is None + + # ... and it clears an existing via device on re-registration + linked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device_id=via.id, + ) + assert linked.via_device_id == via.id + relinked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device=None, + ) + assert relinked.id == linked.id + assert relinked.via_device_id is None + + +async def test_via_device_prefers_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device resolves to the via device in the same config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # Two via devices share an identifier, one per config entry + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} + ) + via_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("hue", "via")} + ) + assert via_1.id != via_2.id + + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_2.id + + +async def test_via_device_falls_back_to_other_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device falls back to a via device in another config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # The via device only exists in entry_1 + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} + ) + + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_1.id + + +async def test_via_device_prefers_same_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device prefers a via device from the same integration. + + When no via device exists in the registering config entry, one from another config + entry of the same domain is preferred over an arbitrary other-domain match. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + other_domain_entry = MockConfigEntry(domain="deconz") + other_domain_entry.add_to_hass(hass) + same_domain_entry = MockConfigEntry(domain="hue") + same_domain_entry.add_to_hass(hass) + + # No via device in `entry`; the other-domain candidate is indexed first + device_registry.async_get_or_create( + config_entry_id=other_domain_entry.entry_id, identifiers={("hue", "via")} + ) + via_same_domain = device_registry.async_get_or_create( + config_entry_id=same_domain_entry.entry_id, identifiers={("hue", "via")} + ) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_same_domain.id + + async def test_loading_saving_data( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -2641,7 +3859,10 @@ async def test_loading_saving_data( suggested_area="Kitchen", ) - assert len(device_registry.devices) == 4 + # config_entry_4's device shares a connection with orig_light3 but belongs to a + # different config entry, so it is a separate device (identifiers/connections are + # unique per config entry) + assert len(device_registry.devices) == 5 assert len(device_registry.deleted_devices) == 1 orig_via = device_registry.async_update_device( @@ -2793,8 +4014,8 @@ async def test_update( assert updated_entry != entry assert updated_entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, configuration_url="https://example.com/config", connections={("mac", "65:43:21:fe:dc:ba")}, created_at=created_at, @@ -2975,411 +4196,62 @@ async def test_update_connection( async def test_update_remove_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) + """Test removing a device's config entry deletes the device.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_update_device( - entry2.id, add_config_entry_id=config_entry_3.entry_id - ) - # Try to add an unknown config entry - with pytest.raises(HomeAssistantError): - device_registry.async_update_device(entry2.id, add_config_entry_id="blabla") + assert entry.config_entry_id == config_entry.entry_id - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id == entry4.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - - device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_1.entry_id - ) - updated_entry = device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_3.entry_id - ) - removed_entry = device_registry.async_update_device( - entry3.id, remove_config_entry_id=config_entry_1.entry_id + # Removing the owning config entry with no pending move deletes the device + updated = device_registry.async_update_device( + entry.id, remove_config_entry_id=config_entry.entry_id ) - assert updated_entry.config_entries == {config_entry_2.entry_id} - assert removed_entry is None - - removed_entry = device_registry.async_get_device(identifiers={("bridgeid", "4567")}) - - assert removed_entry is None - - await hass.async_block_till_done() - - assert len(update_events) == 7 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[6].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 async def test_update_remove_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test removing a device's config subentry deletes the device.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-2", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) - entry_id = entry.id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - } + assert entry.config_subentry_id == "mock-subentry-id-1" - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", - ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - - # Try adding the same subentry again - assert ( - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", - ) - is entry + # Removing the owning config entry/subentry with no pending move deletes the device + updated = device_registry.async_update_device( + entry.id, + remove_config_entry_id=config_entry.entry_id, + remove_config_subentry_id="mock-subentry-id-1", ) - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_2.entry_id, - add_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id=None, - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - # Try to add a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't add config subentry without specifying config entry", - ): - device_registry.async_update_device(entry_id, add_config_subentry_id="blabla") - - # Try to add an unknown subentry - with pytest.raises( - HomeAssistantError, - match=f"Config entry {config_entry_3.entry_id} has no subentry blabla", - ): - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id="blabla", - ) - - # Try to remove a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't remove config subentry without specifying config entry", - ): - device_registry.async_update_device( - entry_id, remove_config_subentry_id="blabla" - ) - - assert len(device_registry.devices) == 1 - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - # Try removing the same subentry again - assert ( - device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - is entry - ) - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-2", - ) - assert entry.config_entries == {config_entry_2.entry_id, config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_2.entry_id, - remove_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_3.entry_id: {None}, - } - - entry_before_remove = entry - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_3.entry_id, - remove_config_subentry_id=None, - ) - assert entry is None - - await hass.async_block_till_done() - - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry_id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - }, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[6].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry_id, - "device": entry_before_remove.dict_repr, - } + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 @pytest.mark.parametrize( @@ -3452,202 +4324,85 @@ async def test_update_suggested_area( @pytest.mark.parametrize( - ( - "new_config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_updated", - "extra_changes", - ), + "device_disabled_by", [ - ( - None, - None, - None, - {}, - ), - # Config entry not disabled, device was disabled by config entry. - # Device not disabled when updated. - ( - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - None, - {"disabled_by": dr.DeviceEntryDisabler.CONFIG_ENTRY}, - ), - ( - None, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - None, - None, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.USER, ], ) @pytest.mark.usefixtures("freezer") async def test_update_add_config_entry_disabled_by( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - new_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, - device_disabled_by_initial: dr.DeviceEntryDisabler | None, - device_disabled_by_updated: dr.DeviceEntryDisabler | None, - extra_changes: dict[str, Any], + device_disabled_by: dr.DeviceEntryDisabler | None, ) -> None: - """Check how the disabled_by flag is treated when adding a config entry.""" + """Check how the disabled_by flag is treated when adding a config entry. + + A device is now owned by a single config entry: add_config_entry_id only records a + transient pending move (completed by a subsequent remove of the current owner), so on + its own it leaves the device - including its disabled_by flag - unchanged. + """ config_entry_1 = MockConfigEntry(title=None) config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - title=None, disabled_by=new_config_entry_disabled_by - ) + config_entry_2 = MockConfigEntry(title=None) config_entry_2.add_to_hass(hass) update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, config_subentry_id=None, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by_initial, + disabled_by=device_disabled_by, ) - assert entry.disabled_by == device_disabled_by_initial + assert entry.disabled_by == device_disabled_by entry2 = device_registry.async_update_device( entry.id, add_config_entry_id=config_entry_2.entry_id ) - assert entry2 == dr.DeviceEntry( - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=device_disabled_by_updated, - id=entry.id, - modified_at=utcnow(), - primary_config_entry=None, - ) + # The device is unchanged: still owned by config_entry_1, same disabled_by + assert entry2.config_entry_id == config_entry_1.entry_id + assert entry2.config_subentry_id is None + assert entry2.disabled_by == device_disabled_by await hass.async_block_till_done() - assert len(update_events) == 2 + # The pending move is never stored, so no update event is fired + assert len(update_events) == 1 assert update_events[0].data == { "action": "create", "device_id": entry.id, } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - } - | extra_changes, - } @pytest.mark.parametrize( - ( - "removed_config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_updated", - "extra_changes", - ), + ("device_disabled_by", "expected_disabled_by"), [ - # The non-disabled config entry is removed, device changed to - # disabled by config entry. - ( - None, - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {"disabled_by": None}, - ), - ( - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - None, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), - # In this test, the device is in an invalid state: config entry disabled, - # device not disabled. After removing the config entry, the device is disabled - # by checking the remaining config entry. - ( - config_entries.ConfigEntryDisabler.USER, - None, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {"disabled_by": None}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - dr.DeviceEntryDisabler.CONFIG_ENTRY, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.INTEGRATION, - dr.DeviceEntryDisabler.INTEGRATION, - {}, - ), - ( - config_entries.ConfigEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - dr.DeviceEntryDisabler.USER, - {}, - ), + # An enabled device moved onto a disabled entry is disabled by CONFIG_ENTRY + (None, dr.DeviceEntryDisabler.CONFIG_ENTRY), + # An existing CONFIG_ENTRY / INTEGRATION / USER disable is preserved + (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), + (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), + (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), ], ) @pytest.mark.usefixtures("freezer") async def test_update_remove_config_entry_disabled_by( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - removed_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, - device_disabled_by_initial: dr.DeviceEntryDisabler | None, - device_disabled_by_updated: dr.DeviceEntryDisabler | None, - extra_changes: dict[str, Any], + device_disabled_by: dr.DeviceEntryDisabler | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, ) -> None: - """Check how the disabled_by flag is treated when removing a config entry.""" - config_entry_1 = MockConfigEntry( - title=None, disabled_by=removed_config_entry_disabled_by - ) + """Check how the disabled_by flag is treated when removing a config entry. + + add_config_entry_id followed by remove_config_entry_id of the current owner moves the + device to the added config entry. The move re-evaluates disabled_by against the new + owning entry (like restoring a deleted device): an enabled device moved onto a + disabled entry becomes CONFIG_ENTRY-disabled, while a USER/INTEGRATION disable - or an + existing CONFIG_ENTRY disable - is kept. + """ + config_entry_1 = MockConfigEntry(title=None) config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry( title=None, disabled_by=config_entries.ConfigEntryDisabler.USER @@ -3658,57 +4413,525 @@ async def test_update_remove_config_entry_disabled_by( config_entry_id=config_entry_1.entry_id, config_subentry_id=None, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by_initial, + disabled_by=device_disabled_by, ) - assert entry.disabled_by == device_disabled_by_initial + assert entry.disabled_by == device_disabled_by - entry2 = device_registry.async_update_device( + # add records a pending move, remove of the current owner performs it + device_registry.async_update_device( entry.id, add_config_entry_id=config_entry_2.entry_id ) - assert entry2.disabled_by == device_disabled_by_initial - entry3 = device_registry.async_update_device( entry.id, remove_config_entry_id=config_entry_1.entry_id ) - assert entry3 == dr.DeviceEntry( - config_entries={config_entry_2.entry_id}, - config_entries_subentries={config_entry_2.entry_id: {None}}, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=device_disabled_by_updated, - id=entry.id, - modified_at=utcnow(), - primary_config_entry=None, - ) + # The device moved to config_entry_2, disabled_by reflecting the new entry + assert entry3 is not None + assert entry3.config_entry_id == config_entry_2.entry_id + assert entry3.config_subentry_id is None + assert entry3.disabled_by == expected_disabled_by await hass.async_block_till_done() - assert len(update_events) == 3 + # create + the move update (the add on its own does not fire an event) + assert len(update_events) == 2 assert update_events[0].data == { "action": "create", "device_id": entry.id, } + expected_changes: dict[str, Any] = {"config_entry_id": config_entry_1.entry_id} + if expected_disabled_by != device_disabled_by: + expected_changes["disabled_by"] = device_disabled_by assert update_events[1].data == { "action": "update", "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, + "changes": expected_changes, + } + + +async def test_move_to_enabled_config_entry_clears_config_entry_disable( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device to an enabled config entry clears a CONFIG_ENTRY disable. + + The reverse of moving onto a disabled entry; a USER disable is preserved. + """ + disabled_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER + ) + disabled_entry.add_to_hass(hass) + enabled_entry = MockConfigEntry() + enabled_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "1")}, + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, + ) + device_registry.async_update_device( + device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved is not None + assert moved.config_entry_id == enabled_entry.entry_id + assert moved.disabled_by is None + + user_device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "2")}, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + device_registry.async_update_device( + user_device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved_user = device_registry.async_update_device( + user_device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved_user is not None + assert moved_user.disabled_by is dr.DeviceEntryDisabler.USER + + +async def test_move_to_config_entry_with_colliding_identity_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device onto a config entry that already has its identity raises. + + Identifiers and connections are unique per config entry, so a move must validate the + device's retained identity against the target entry instead of silently overwriting + the existing device's index slot. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_2.entry_id + ) + + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device_c = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, connections={mac} + ) + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, connections={mac} + ) + with pytest.raises(dr.DeviceConnectionCollisionError): + device_registry.async_update_device( + device_c.id, new_config_entry_id=entry_2.entry_id + ) + + +async def test_add_identifier_keeps_other_config_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Adding an identifier does not delete a matching deleted device of another entry. + + Deleted devices are per config entry now, so a device in entry A merging an + identifier must not wipe entry B's deleted-device metadata (its restore data). + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "a")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + device_registry.async_update_device(device_b.id, name_by_user="Custom B") + device_b_id = device_b.id + device_registry.async_remove_device(device_b.id) + + # entry A's device merges the identifier entry B's deleted device also has + device_registry.async_update_device( + device_a.id, merge_identifiers={("test", "shared")} + ) + + # entry B's deleted device survives, so re-registering restores its id and metadata + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + assert restored_b.id == device_b_id + assert restored_b.name_by_user == "Custom B" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_remaps_via_device_id_to_split( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A child's via_device_id is remapped to a live parent split. + + To the split in the child's own config entry when the parent spanned it, otherwise to + one of the parent's splits - never left dangling on the removed composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="dom_c") + entry_c.add_to_hass(hass) + + def _device(id_: str, entries: list[str], identifiers, via: str | None) -> dict: + return { + "area_id": None, + "config_entries": entries, + "config_entries_subentries": {entry: [None] for entry in entries}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": id_, + "identifiers": identifiers, + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entries[0], + "serial_number": None, + "sw_version": None, + "via_device_id": via, + } + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + _device( + "parent000000000000000000000000", + [entry_a.entry_id, entry_b.entry_id], + [["dom_a", "p"], ["dom_b", "p"]], + None, + ), + _device( + "child0000000000000000000000000", + [entry_a.entry_id], + [["dom_a", "c"]], + "parent000000000000000000000000", + ), + # child in a config entry the parent does not span + _device( + "childc000000000000000000000000", + [entry_c.entry_id], + [["dom_c", "c"]], + "parent000000000000000000000000", + ), + ], + "deleted_devices": [], }, } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - }, - } - | extra_changes, + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The parent splits (fresh split ids, not the old composite id) + parent_a = registry.async_get_device(identifiers={("dom_a", "p")}) + parent_b = registry.async_get_device(identifiers={("dom_b", "p")}) + assert parent_a is not None + assert parent_b is not None + assert parent_a.config_entry_id == entry_a.entry_id + assert parent_a.id != "parent000000000000000000000000" + + # The child in entry_a points at the parent's entry_a split + child = registry.async_get_device(identifiers={("dom_a", "c")}) + assert child is not None + assert child.via_device_id == parent_a.id + + # The child in entry_c, which the parent did not span, points at one of the parent's + # splits rather than the removed composite id + child_c = registry.async_get_device(identifiers={("dom_c", "c")}) + assert child_c is not None + assert child_c.via_device_id in {parent_a.id, parent_b.id} + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.parametrize( + ("composite_disabled_by", "expected_split_enabled", "expected_split_disabled"), + [ + pytest.param( + None, None, dr.DeviceEntryDisabler.CONFIG_ENTRY, id="enabled_composite" + ), + pytest.param( + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + id="user_disabled", + ), + pytest.param( + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + id="config_entry_disabled", + ), + ], +) +async def test_migration_split_disabled_by_follows_config_entry( + hass: HomeAssistant, + hass_storage: dict[str, Any], + composite_disabled_by: dr.DeviceEntryDisabler | None, + expected_split_enabled: dr.DeviceEntryDisabler | None, + expected_split_disabled: dr.DeviceEntryDisabler, +) -> None: + """A split's disabled_by follows its single owning config entry's disabled state. + + A composite spanning an enabled and a disabled config entry copies its disabled_by to + both splits; each split is then reconciled against its own entry - the split owned by + the disabled entry becomes CONFIG_ENTRY disabled (a USER disable is preserved), while + the split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared. + """ + entry_enabled = MockConfigEntry(domain="dom_a") + entry_enabled.add_to_hass(hass) + entry_disabled = MockConfigEntry( + domain="dom_b", disabled_by=config_entries.ConfigEntryDisabler.USER + ) + entry_disabled.add_to_hass(hass) + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [ + entry_enabled.entry_id, + entry_disabled.entry_id, + ], + "config_entries_subentries": { + entry_enabled.entry_id: [None], + entry_disabled.entry_id: [None], + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": composite_disabled_by, + "entry_type": None, + "hw_version": None, + "id": "composite00000000000000000000", + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_enabled.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + split_enabled = registry.async_get_device(identifiers={("dom_a", "x")}) + split_disabled = registry.async_get_device(identifiers={("dom_b", "x")}) + assert split_enabled is not None + assert split_disabled is not None + assert split_enabled.config_entry_id == entry_enabled.entry_id + assert split_disabled.config_entry_id == entry_disabled.entry_id + # The split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared + assert split_enabled.disabled_by is expected_split_enabled + # The split owned by the disabled entry follows that entry (USER preserved) + assert split_disabled.disabled_by is expected_split_disabled + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_disabled_by_not_reconciled_without_composite_split( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """disabled_by is reconciled only for split composites, not other migrated devices. + + A 1.12 -> 1.13 migration that splits no composite does not touch a device whose stored + disabled_by does not match its config entry. + """ + entry = MockConfigEntry( + domain="dom_a", disabled_by=config_entries.ConfigEntryDisabler.USER + ) + entry.add_to_hass(hass) + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device000000000000000000000000", + "identifiers": [["dom_a", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + device = registry.async_get_device(identifiers={("dom_a", "x")}) + assert device is not None + # The reconcile is gated on a composite split, so disabled_by is left as stored + assert device.disabled_by is None + + +@pytest.mark.parametrize("config_entry_disabled", [False, True]) +@pytest.mark.parametrize( + "initial_disabled_by", + [ + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.USER, + ], +) +async def test_migrate_device_disabled_by_matches_runtime( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + initial_disabled_by: dr.DeviceEntryDisabler | None, + config_entry_disabled: bool, +) -> None: + """The migration dict reconcile matches async_config_entry_disabled_by_changed. + + _migrate_device_disabled_by reimplements the runtime helper on stored data, so for + every combination of device disabled_by and config entry state both must agree. + """ + config_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER + if config_entry_disabled + else None + ) + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "1")} + ) + # Explicit disabled_by bypasses async_update_device's own reconciliation + device_registry.async_update_device(device.id, disabled_by=initial_disabled_by) + + # Runtime helper on the loaded registry + dr.async_config_entry_disabled_by_changed(device_registry, config_entry) + runtime_result = device_registry.async_get(device.id).disabled_by + + # Migration helper on the stored representation + stored = {"disabled_by": initial_disabled_by} + dr._migrate_device_disabled_by(stored, config_entry_disabled) + + assert stored["disabled_by"] == runtime_result + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_composite_lineage_not_restored_after_remove( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A migrated split loses its composite lineage once removed. + + The deleted device does not carry composite data, so re-registering the split makes a + plain device that no longer resolves from the pre-migration composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + + old_id = "composite00000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": old_id, + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + split_a = registry.async_get_device(identifiers={("dom_a", "x")}) + assert split_a is not None + assert split_a.composite_device_id == old_id + + # Remove the split; the deleted device does not carry the composite lineage + registry.async_remove_device(split_a.id) + + # Re-registering reuses the deleted device's id but drops the composite lineage + restored = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("dom_a", "x")} + ) + assert restored.id == split_a.id + assert restored.composite_device_id is None + assert restored not in registry.async_get_devices_for_composite_device_id(old_id) async def test_cleanup_device_registry( @@ -3900,8 +5123,8 @@ async def test_restore_device( ) assert entry2 == dr.DeviceEntry( area_id=None, - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + config_entry_id=entry_id, + config_subentry_id=None, configuration_url=None, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")}, created_at=utcnow(), @@ -3917,7 +5140,6 @@ async def test_restore_device( modified_at=utcnow(), name_by_user=None, name=None, - primary_config_entry=entry_id, serial_number=None, sw_version=None, ) @@ -3942,8 +5164,8 @@ async def test_restore_device( ) assert entry3 == dr.DeviceEntry( area_id=initial_area, - config_entries={entry_id}, - config_entries_subentries={entry_id: {subentry_id}}, + config_entry_id=entry_id, + config_subentry_id=subentry_id, configuration_url="http://config_url_new.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, created_at=utcnow(), @@ -3959,7 +5181,6 @@ async def test_restore_device( modified_at=utcnow(), name_by_user="Test Friendly Name", name="name_new", - primary_config_entry=entry_id, serial_number="serial_no_new", suggested_area="suggested_area_new", sw_version="version_new", @@ -4081,8 +5302,8 @@ async def test_restore_migrated_device_disabled_by( ) assert entry3 == dr.DeviceEntry( area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + config_entry_id=entry_id, + config_subentry_id=None, configuration_url="http://config_url_new.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, created_at=utcnow(), @@ -4098,7 +5319,6 @@ async def test_restore_migrated_device_disabled_by( modified_at=utcnow(), name_by_user=None, name="name_new", - primary_config_entry=entry_id, serial_number="serial_no_new", suggested_area="suggested_area_new", sw_version="version_new", @@ -4249,8 +5469,8 @@ async def test_restore_disabled_by( ) assert entry3 == dr.DeviceEntry( area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + config_entry_id=entry_id, + config_subentry_id=None, configuration_url="http://config_url_new.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, created_at=utcnow(), @@ -4266,7 +5486,6 @@ async def test_restore_disabled_by( modified_at=utcnow(), name_by_user=None, name="name_new", - primary_config_entry=entry_id, serial_number="serial_no_new", suggested_area="suggested_area_new", sw_version="version_new", @@ -4298,353 +5517,6 @@ async def test_restore_disabled_by( } -@pytest.mark.usefixtures("freezer") -async def test_restore_shared_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Make sure device id is stable for shared devices.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ), - ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_orig_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_orig_1", - model="model_orig_1", - model_id="model_id_orig_1", - name="name_orig_1", - serial_number="serial_no_orig_1", - suggested_area="suggested_area_orig_1", - sw_version="version_orig_1", - via_device="via_device_id_orig_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Add another config entry to the same device - device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - configuration_url="http://config_url_orig_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_orig_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - name="name_orig_2", - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - via_device="via_device_id_orig_2", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Apply user customizations - updated_device = device_registry.async_update_device( - entry.id, - area_id="12345A", - disabled_by=dr.DeviceEntryDisabler.USER, - labels={"label1", "label2"}, - name_by_user="Test Friendly Name", - ) - - # Check device entry before we remove it - assert updated_device == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_orig_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_orig_2", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_orig_2", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - ) - - device_registry.async_remove_device(entry.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - # config_entry_1 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, user - # customizations of area_id, disabled_by, labels and name_by_user will be restored. - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", - ) - - assert entry2 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id}, - config_entries_subentries={config_entry_1.entry_id: {"mock-subentry-id-1-1"}}, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry2.config_entries, set) - assert isinstance(entry2.connections, set) - assert isinstance(entry2.identifiers, set) - - # Remove the device again - device_registry.async_remove_device(entry.id) - - # config_entry_2 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, user - # customizations of area_id, disabled_by, labels and name_by_user will be restored. - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_new_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - name="name_new_2", - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - via_device="via_device_id_new_2", - ) - - assert entry3 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_new_2", - id=entry.id, - identifiers={("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_2", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry3.config_entries, set) - assert isinstance(entry3.connections, set) - assert isinstance(entry3.identifiers, set) - - # Add config_entry_1 back to the restored device - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", - ) - - assert entry4 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry4.config_entries, set) - assert isinstance(entry4.connections, set) - assert isinstance(entry4.identifiers, set) - - await hass.async_block_till_done() - - assert len(update_events) == 8 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - }, - "configuration_url": "http://config_url_orig_1.bla", - "entry_type": dr.DeviceEntryType.SERVICE, - "hw_version": "hw_version_orig_1", - "identifiers": {("entry_123", "0123")}, - "manufacturer": "manufacturer_orig_1", - "model": "model_orig_1", - "model_id": "model_id_orig_1", - "name": "name_orig_1", - "serial_number": "serial_no_orig_1", - "suggested_area": "suggested_area_orig_1", - "sw_version": "version_orig_1", - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "area_id": "suggested_area_orig_1", - "disabled_by": None, - "labels": set(), - "name_by_user": None, - }, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": updated_device.dict_repr, - } - assert update_events[4].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[5].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[6].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[7].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_2.entry_id}, - "config_entries_subentries": {config_entry_2.entry_id: {None}}, - "configuration_url": "http://config_url_new_2.bla", - "entry_type": None, - "hw_version": "hw_version_new_2", - "identifiers": {("entry_234", "2345")}, - "manufacturer": "manufacturer_new_2", - "model": "model_new_2", - "model_id": "model_id_new_2", - "name": "name_new_2", - "serial_number": "serial_no_new_2", - "suggested_area": "suggested_area_new_2", - "sw_version": "version_new_2", - }, - } - - async def test_get_or_create_empty_then_set_default_values( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, @@ -4823,50 +5695,6 @@ async def test_disable_config_entry_disables_devices( assert entry2.disabled_by is dr.DeviceEntryDisabler.USER -async def test_only_disable_device_if_all_config_entries_are_disabled( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Test that we only disable device if all related config entries are disabled.""" - config_entry1 = MockConfigEntry(domain="light") - config_entry1.add_to_hass(hass) - config_entry2 = MockConfigEntry(domain="light") - config_entry2.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=config_entry1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry1 = device_registry.async_get_or_create( - config_entry_id=config_entry2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert len(entry1.config_entries) == 2 - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry1.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry2.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert entry1.disabled - assert entry1.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY - - await hass.config_entries.async_set_disabled_by(config_entry1.entry_id, None) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - - @pytest.mark.parametrize( ("configuration_url", "expectation"), [ @@ -4999,8 +5827,14 @@ async def test_loading_invalid_configuration_url_from_storage( "devices": [ { "area_id": None, - "config_entries": ["1234"], - "config_entries_subentries": {"1234": [None]}, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": "invalid", "connections": [], "created_at": "2024-01-01T00:00:00+00:00", @@ -5016,7 +5850,7 @@ async def test_loading_invalid_configuration_url_from_storage( "modified_at": "2024-02-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": "1234", + "primary_config_entry": mock_config_entry.entry_id, "serial_number": None, "sw_version": None, "via_device_id": None, @@ -5619,78 +6453,6 @@ async def test_device_registry_deleted_device_collision( assert len(device_registry.deleted_devices) == 0 -async def test_primary_config_entry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, -) -> None: - """Test the primary integration field.""" - mock_config_entry_1 = MockConfigEntry(domain="mqtt", title=None) - mock_config_entry_1.add_to_hass(hass) - mock_config_entry_2 = MockConfigEntry(title=None) - mock_config_entry_2.add_to_hass(hass) - mock_config_entry_3 = MockConfigEntry(title=None) - mock_config_entry_3.add_to_hass(hass) - mock_config_entry_4 = MockConfigEntry(domain="matter", title=None) - mock_config_entry_4.add_to_hass(hass) - - # Create device without model name etc, config entry will not be marked primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), - ) - assert device.primary_config_entry is None - - # Set model, mqtt config entry will be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model", - ) - assert device.primary_config_entry == mock_config_entry_1.entry_id - - # New config entry with model will be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 2", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # New config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_3.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # New matter config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_4.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # Remove the primary config entry - device = device_registry.async_update_device( - device.id, - remove_config_entry_id=mock_config_entry_2.entry_id, - ) - assert device.primary_config_entry is None - - # Create new - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), - manufacturer="manufacturer", - model="model", - ) - assert device.primary_config_entry == mock_config_entry_1.entry_id - - async def test_update_device_no_connections_or_identifiers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -5713,7 +6475,10 @@ async def test_update_device_no_connections_or_identifiers( async def test_connections_validator() -> None: """Test checking connections validator.""" with pytest.raises(ValueError, match="Invalid mac address format"): - dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}) + dr.DeviceEntry( + config_entry_id="mock-config-entry", + connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}, + ) async def test_suggested_area_deprecation( @@ -5755,3 +6520,946 @@ async def test_suggested_area_deprecation( "device. This will stop working in Home Assistant 2026.9.0, please report " "this issue" ) in caplog.text + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +async def test_single_config_entry_and_compat_properties( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device has a single config entry; the deprecated shims reflect it.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id is None + assert device.config_entries == {entry.entry_id} + assert device.config_entries_subentries == {entry.entry_id: {None}} + assert device.primary_config_entry == entry.entry_id + + +async def test_identifiers_unique_per_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The same identifier under two config entries yields two devices.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("shared", "1")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("shared", "1")} + ) + + assert device_a.id != device_b.id + + # Scoped lookup returns the owning device + assert ( + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("shared", "1")} + ).id + == device_a.id + ) + assert ( + _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("shared", "1")} + ).id + == device_b.id + ) + + +async def test_collision_only_within_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A collision is raised only for two devices of the same config entry.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + other = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "2")} + ) + + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + other.id, merge_identifiers={("domain_a", "1")} + ) + assert device_registry.async_get(device.id) is not None + + +async def test_remove_shadowed_collision_keeps_index_consistent( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Removing a device that shadows a same-entry collision keeps the index consistent. + + allow_collisions lets a device absorb an identifier another device of the same config + entry holds, shadowing it in the index. When a second config entry also shares that + identifier, removing the shadowed device then the indexed one must not delete the wrong + slot or raise KeyError on the mapping the second entry keeps. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "1")} + ) + shadowed = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "2")} + ) + # The second config entry keeps its own slot for the shared identifier + other_entry_device = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "2")} + ) + # allow_collisions lets `device` absorb the shadowed device's identifier + device_registry._async_update_device( + device.id, merge_identifiers={("test", "2")}, allow_collisions=True + ) + assert device_registry.async_get(device.id).identifiers == { + ("test", "1"), + ("test", "2"), + } + assert shadowed.id in device_registry.devices + + # Remove the shadowed device, then the indexed one - neither must raise + device_registry.async_remove_device(shadowed.id) + device_registry.async_remove_device(device.id) + + # The second config entry's device is still reachable by the shared identifier + assert ( + device_registry.async_get_device(identifiers={("test", "2")}) + is other_entry_device + ) + + +@pytest.mark.parametrize( + ("identity", "merge_kwarg", "merge_extra", "error"), + [ + pytest.param( + {"identifiers": {("test", "shared")}}, + "merge_identifiers", + {("test", "extra")}, + dr.DeviceIdentifierCollisionError, + id="identifiers", + ), + pytest.param( + {"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}}, + "merge_connections", + {(dr.CONNECTION_NETWORK_MAC, "ab:cd:ef:12:34:56")}, + dr.DeviceConnectionCollisionError, + id="connections", + ), + ], +) +async def test_move_with_merge_validates_retained_identity( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + identity: dict[str, set[tuple[str, str]]], + merge_kwarg: str, + merge_extra: set[tuple[str, str]], + error: type[Exception], +) -> None: + """A move that also merges must validate the retained identity against the target. + + The merged additions are validated, but the retained old identity must be too, or the + move silently overwrites the target entry's index slot for a device already there. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, **identity + ) + # entry_b already owns a device with the same identity + device_registry.async_get_or_create(config_entry_id=entry_b.entry_id, **identity) + + # Moving device to entry_b retains its identity, which collides with entry_b's + # existing device, so the move must raise rather than silently shadow it. + with pytest.raises(error): + device_registry.async_update_device( + device.id, + new_config_entry_id=entry_b.entry_id, + **{merge_kwarg: merge_extra}, + ) + + +async def test_move_two_calls_add_then_remove( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test add_config_entry_id records a pending move; the later remove performs it.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + # add alone does nothing yet + device_registry.async_update_device(device.id, add_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_a.entry_id + + # remove of the current owner performs the pending move + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id + ) + moved = device_registry.async_get(device.id) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + + +async def test_move_new_config_entry_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test new_config_entry_id moves the device immediately.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + device_registry.async_update_device(device.id, new_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_b.entry_id + + +async def test_move_new_and_add_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test mixing new_config_entry_id with add/remove raises.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + with pytest.raises(HomeAssistantError, match="Can't combine"): + device_registry.async_update_device( + device.id, + new_config_entry_id=entry_b.entry_id, + add_config_entry_id=entry_b.entry_id, + ) + + +async def test_async_get_or_create_unknown_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test async_get_or_create raises for an unknown config entry.""" + with pytest.raises( + HomeAssistantError, + match="Can't link device to unknown config entry unknown-config-entry", + ): + device_registry.async_get_or_create( + config_entry_id="unknown-config-entry", identifiers={("bridgeid", "0123")} + ) + + +@pytest.mark.parametrize( + ("make_update_kwargs", "error_match"), + [ + pytest.param( + lambda entry: {"add_config_entry_id": "unknown-config-entry"}, + "Can't link device to unknown config entry unknown-config-entry", + id="add-unknown-config-entry", + ), + pytest.param( + lambda entry: {"add_config_subentry_id": "mock-subentry-id-2"}, + "Can't add config subentry without specifying config entry", + id="add-subentry-without-config-entry", + ), + pytest.param( + lambda entry: { + "add_config_entry_id": entry.entry_id, + "add_config_subentry_id": "unknown-subentry", + }, + "has no subentry unknown-subentry", + id="add-unknown-subentry", + ), + pytest.param( + lambda entry: {"remove_config_subentry_id": "mock-subentry-id-1"}, + "Can't remove config subentry without specifying config entry", + id="remove-subentry-without-config-entry", + ), + pytest.param( + lambda entry: {"new_config_entry_id": "unknown-config-entry"}, + "Can't move device to unknown config entry unknown-config-entry", + id="new-unknown-config-entry", + ), + pytest.param( + lambda entry: {"new_config_subentry_id": "unknown-subentry"}, + "has no subentry unknown-subentry", + id="new-unknown-subentry", + ), + pytest.param( + lambda entry: { + "new_config_entry_id": entry.entry_id, + "add_config_entry_id": entry.entry_id, + }, + "Can't combine new_config_entry_id or new_config_subentry_id", + id="combine-new-and-add", + ), + ], +) +async def test_update_device_config_entry_grammar_errors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + make_update_kwargs: Callable[[MockConfigEntry], dict[str, Any]], + error_match: str, +) -> None: + """The config-entry/subentry mutation grammar validates its arguments.""" + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("bridgeid", "0123")}, + ) + + with pytest.raises(HomeAssistantError, match=error_match): + device_registry.async_update_device(device.id, **make_update_kwargs(entry)) + + +async def test_move_device_to_config_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device can be moved to another subentry of its config entry. + + Immediately via new_config_subentry_id, or deferred via a pending move + (add_config_entry_id + add_config_subentry_id, completed by removing the current + owner). There is no subentry-only deferred move - add_config_subentry_id and + remove_config_subentry_id without a config entry raise (see + test_update_device_config_entry_grammar_errors). + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("bridgeid", "0123")}, + ) + + # new_config_subentry_id moves the device immediately + moved = device_registry.async_update_device( + device.id, new_config_subentry_id="mock-subentry-id-2" + ) + assert moved.config_entry_id == entry.entry_id + assert moved.config_subentry_id == "mock-subentry-id-2" + + # Deferred move: adding the (same) config entry with the target subentry records a + # pending move; it does not move the device on its own + device_registry.async_update_device( + device.id, + add_config_entry_id=entry.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + assert ( + device_registry.async_get(device.id).config_subentry_id == "mock-subentry-id-2" + ) + # Removing the current owner performs the pending move to the target subentry + moved_back = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + assert moved_back is not None + assert moved_back.config_subentry_id == "mock-subentry-id-1" + + +async def test_move_device_to_config_entry_and_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A deferred move can target another config entry and one of its subentries.""" + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-b", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("bridgeid", "0123")} + ) + + # The pending move carries the (config entry, subentry) pair + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_b.entry_id, + add_config_subentry_id="mock-subentry-id-b", + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + assert moved.config_subentry_id == "mock-subentry-id-b" + + +async def test_pending_move_overwritten_by_later_add( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A later add_config_entry_id / add_config_subentry_id overwrites the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Each add records a pending move, overwriting the previous one: first a subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + # ... a later add to the same entry overwrites just the subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-2", + ) + # ... a later add to a different entry overwrites the entry (subentry resets to None) + device_registry.async_update_device(device.id, add_config_entry_id=entry_3.entry_id) + + # None of the adds moved the device + assert device_registry.async_get(device.id).config_entry_id == entry_1.entry_id + + # Removing the owner performs the last recorded pending move + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_3.entry_id + assert moved.config_subentry_id is None + + +async def test_new_config_entry_id_clears_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An immediate new_config_entry_id move clears an earlier pending move. + + Otherwise removing the new owner would perform the stale deferred move instead of + deleting the device, which has no other config entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Record a pending move to entry_2, then immediately move the device to entry_3 + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(device.id, new_config_entry_id=entry_3.entry_id) + assert device_registry.async_get(device.id)._pending_move is None + + # Removing the new owner deletes the device rather than performing the stale move + assert ( + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_3.entry_id + ) + is None + ) + assert device_registry.async_get(device.id) is None + + +async def test_pending_move_canceled_by_cross_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from a different integration than the one that armed the move cancels it. + + Otherwise an incidental add_config_entry_id (e.g. device_tracker attaching a shared + MAC) would hijack the owning integration's later cleanup and move the device instead + of deleting it. + """ + entry_owner = MockConfigEntry(domain="owner") + entry_owner.add_to_hass(hass) + entry_target = MockConfigEntry(domain="attacher") + entry_target.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_owner.entry_id, identifiers={("test", "1")} + ) + + # The "attacher" integration arms a deferred move to its own entry + with patch.object(dr, "_current_integration_domain", return_value="attacher"): + device_registry.async_update_device( + device.id, add_config_entry_id=entry_target.entry_id + ) + assert ( + device_registry.async_get(device.id)._pending_move.origin_domain == "attacher" + ) + + # The owning integration later removes its entry - a different domain, so the stale + # move is canceled and the device is deleted rather than transferred. + with patch.object(dr, "_current_integration_domain", return_value="owner"): + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_owner.entry_id + ) + assert result is None + assert device_registry.async_get(device.id) is None + + +async def test_pending_move_completed_by_same_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from the same integration that armed the move completes it.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + + with patch.object(dr, "_current_integration_domain", return_value="mover"): + device_registry.async_update_device( + device.id, add_config_entry_id=entry_2.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert device_registry.async_get(device.id) is moved + + +async def test_composite_move_clears_sibling_pending_moves( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Completing one split's move clears the pending move on its composite siblings. + + Arming add_config_entry_id on a composite fans out to every split; once one split + moves to the target, the others must not also move there and collide. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + entry_target = MockConfigEntry(domain="test") + entry_target.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # Arm a deferred move on the composite id: fans out to both splits + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + old_id, add_config_entry_id=entry_target.entry_id + ) + assert device_registry.async_get(device_1.id)._pending_move is not None + assert device_registry.async_get(device_2.id)._pending_move is not None + + # Complete the move on split 1; split 2's pending move must be cleared + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + device_1.id, remove_config_entry_id=entry_1.entry_id + ) + assert ( + device_registry.async_get(device_1.id).config_entry_id == entry_target.entry_id + ) + assert device_registry.async_get(device_2.id)._pending_move is None + + # Split 2's own removal now deletes it instead of colliding on the shared identifier + with patch.object(dr, "_current_integration_domain", return_value="test"): + assert ( + device_registry.async_update_device( + device_2.id, remove_config_entry_id=entry_2.entry_id + ) + is None + ) + assert device_registry.async_get(device_2.id) is None + + +async def test_add_and_remove_config_entry_in_one_call( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """add_config_entry_id and remove_config_entry_id of the owner move in a single call.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Adding the new entry/subentry and removing the current owner in one call moves at once + moved = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + remove_config_entry_id=entry_1.entry_id, + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert moved.config_subentry_id == "mock-subentry-id-1" + + await hass.async_block_till_done() + assert len(update_events) == 2 + assert update_events[1].data == { + "action": "update", + "device_id": device.id, + "changes": { + "config_entry_id": entry_1.entry_id, + "config_subentry_id": None, + }, + } + + +async def test_remove_non_owner_config_entry_keeps_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """remove_config_entry_id of a non-owning entry does not perform the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Add a pending move to entry_2, but remove a config entry the device does not own + result = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + remove_config_entry_id=entry_3.entry_id, + ) + # The device is neither moved nor removed: only removing the owner performs the move + assert result is not None + assert result.config_entry_id == entry_1.entry_id + + # The pending move to entry_2 was still recorded; removing the owner now performs it + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_reregistration_replaces_composite_identifiers( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """First re-registration replaces the copied identifiers with the provided ones.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a.has_composite_identifiers is True + + reregistered = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert reregistered.id == split_a.id + assert reregistered.identifiers == {("domain_a", "1")} # domain_b copy pruned + # assert the copied composite connection is cleared + assert reregistered.connections == set() + assert reregistered.has_composite_identifiers is False + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_returns_restored_composite( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test async_get on the legacy id returns a merged, on-demand composite.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.id == COMPOSITE_ID + assert composite.config_entries == {entry_a.entry_id, entry_b.entry_id} + assert composite.config_entries_subentries == { + entry_a.entry_id: {None}, + entry_b.entry_id: {None}, + } + assert composite.identifiers == {("domain_a", "1"), ("domain_b", "1")} + assert composite.serial_number == "SERIAL" + + # Invisible to membership, enumeration and identifier search + assert COMPOSITE_ID not in device_registry.devices + assert COMPOSITE_ID not in {d.id for d in device_registry.devices.values()} + assert ( + device_registry.async_get_device(identifiers={("domain_a", "1")}).id + != COMPOSITE_ID + ) + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_restored_composite_preserves_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The restored composite reports the pre-migration composite's former primary. + + The composite's primary_config_entry is recorded on each split device + (composite_primary_config_entry) so the restored composite can report it, even when + it is not the first split. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's primary is entry_b, which is not its first config entry + storage = _composite_device_storage(entry_a, entry_b) + storage["data"]["devices"][0]["primary_config_entry"] = entry_b.entry_id + hass_storage[dr.STORAGE_KEY] = storage + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + + # The former primary (entry_b) is preserved, even though it is not the first split + assert composite.primary_config_entry == entry_b.entry_id + assert composite.primary_config_entry != splits[0].config_entry_id + # It is a valid member of the merged config entries + assert composite.primary_config_entry in composite.config_entries + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_config_entry_clears_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Clearing the composite's former primary config entry clears the dangling ref.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b.composite_primary_config_entry == entry_a.entry_id + + # Clearing entry_a removes its split and clears the reference on entry_b's split + device_registry.async_clear_config_entry(entry_a.entry_id) + + assert ( + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + is None + ) + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b is not None + assert split_b.composite_primary_config_entry is None + + # The restored composite still works, falling back to the remaining split + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.primary_config_entry == entry_b.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_non_primary_config_entry_keeps_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Clearing a non-primary config entry leaves composite_primary_config_entry intact.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + # Clearing entry_b (not the former primary) removes its split but keeps the reference + device_registry.async_clear_config_entry(entry_b.entry_id) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a is not None + assert split_a.composite_primary_config_entry == entry_a.entry_id + + +async def test_dict_repr_dual_writes_deprecated_keys( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test dict_repr exposes both the new and the deprecated compatibility keys.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + repr_ = device.dict_repr + assert repr_["config_entry_id"] == entry.entry_id + assert repr_["config_subentry_id"] is None + assert repr_["config_entries"] == [entry.entry_id] + assert repr_["config_entries_subentries"] == {entry.entry_id: [None]} + assert repr_["primary_config_entry"] == entry.entry_id + # Internal split-migration fields are not exposed in dict_repr + assert "composite_device_id" not in repr_ + assert "composite_primary_config_entry" not in repr_ + assert "split_at" not in repr_ + assert "has_composite_identifiers" not in repr_ diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index a24f7f4b994a..532a594d33b4 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -554,6 +554,39 @@ async def test_entity_registry_loading_waits_for_device_registry( assert registry.async_get("test.my_entity") is not None +@pytest.mark.parametrize("load_registries", [False]) +async def test_entity_load_detaches_from_dropped_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity referencing a device that no longer exists is detached on load. + + The device migration drops a device with no config entry; an entity that pointed at + it must be detached rather than left on a removed device id. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "test.my_entity", + "device_id": "gone-device", + "platform": "test_platform", + "unique_id": "unique-1", + }, + ] + }, + } + + dr.async_setup(hass) + await asyncio.gather(er.async_load(hass), dr.async_load(hass)) + + registry = er.async_get(hass) + entity = registry.async_get("test.my_entity") + assert entity is not None + assert entity.device_id is None + + def test_get_available_entity_id_considers_registered_entities( entity_registry: er.EntityRegistry, ) -> None: @@ -1813,6 +1846,12 @@ async def test_migration_1_21( "area_id": None, "config_entries": ["mock_entry"], "config_entries_subentries": {"mock_entry": [None]}, + "config_entry_id": "mock_entry", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -2777,66 +2816,59 @@ async def test_remove_config_entry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config entry is removed.""" + """Test that we remove entities tied to a device when its config entry is removed.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") config_entry_2.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( + # Same connections on different config entries are separate devices + device_entry_1 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } + assert device_entry_1.id != device_entry_2.id - # Create one entity for each config entry + # Create one entity for each device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry_1, - device_id=device_entry.id, + device_id=device_entry_1.id, ) - entry_2 = entity_registry.async_get_or_create( "sensor", "device_tracker", "6789", config_entry=config_entry_2, - device_id=device_entry.id, + device_id=device_entry_2.id, ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device, the entity associated with it - # should be removed + # Removing the first config entry removes its device and the tied entity device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_1.entry_id + device_entry_1.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) + assert not device_registry.async_get(device_entry_1.id) assert not entity_registry.async_is_registered(entry_1.entity_id) + assert device_registry.async_get(device_entry_2.id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the second config entry from the device, the entity associated with it - # (and the device itself) should be removed + # Removing the second config entry removes its device and entity too device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry_2.id, remove_config_entry_id=config_entry_2.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not device_registry.async_get(device_entry_2.id) assert not entity_registry.async_is_registered(entry_2.entity_id) @@ -2845,72 +2877,148 @@ async def test_remove_config_entry_from_device_removes_entities_2( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config entry.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry(domain="device_tracker") + config_entry_2 = MockConfigEntry(domain="some_helper") config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry(domain="some_helper") - config_entry_3.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } - # Create an entity without config entry + # An entity without a config entry, tied to the device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity with a config entry not in the device + # An entity with a different config entry, tied to the device entry_2 = entity_registry.async_get_or_create( "light", "some_helper", "5678", - config_entry=config_entry_3, + config_entry=config_entry_2, device_id=device_entry.id, ) - assert entry_1.entity_id != entry_2.entity_id assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device + # Removing the device's config entry removes the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - # Entities which are not tied to the removed config entry should not be removed + assert not device_registry.async_get(device_entry.id) + # Entities not tied to the removed config entry are kept, but detached assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None - # Remove the second config entry from the device (this removes the device) + +async def test_move_device_config_entry_removes_old_entry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Moving a device to another config entry removes the old entry's entities.""" + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="tado") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="some_helper") + entry_c.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("hue", "1")} + ) + # An entity owned by the departing entry A, and a helper entity of a third entry C + entry_a_entity = entity_registry.async_get_or_create( + "light", "hue", "a", config_entry=entry_a, device_id=device_entry.id + ) + entry_c_entity = entity_registry.async_get_or_create( + "sensor", "some_helper", "c", config_entry=entry_c, device_id=device_entry.id + ) + + # Move the device from entry A to entry B (an update, not a removal) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry.id, new_config_entry_id=entry_b.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - # Entities which are not tied to a config entry in the device should not be removed - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - # Check the device link is set to None - assert entity_registry.async_get(entry_1.entity_id).device_id is None - assert entity_registry.async_get(entry_2.entity_id).device_id is None + # A no longer owns the device, so A's entity is removed; C's helper is untouched + assert not entity_registry.async_is_registered(entry_a_entity.entity_id) + assert entity_registry.async_is_registered(entry_c_entity.entity_id) + + +@pytest.mark.parametrize("old_subentry_id", [None, "sub-1"]) +async def test_move_device_config_subentry_removes_old_subentry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + old_subentry_id: str | None, +) -> None: + """Moving a device to another subentry removes the old subentry's entities. + + Includes a departing subentry of None (the main entry): the change is detected by the + old config_subentry_id being present in the event, not by its truthiness. + """ + config_entry = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=old_subentry_id, + identifiers={("hue", "1")}, + ) + # Entity on the departing subentry, and one on the destination subentry sub-2 + old_entity = entity_registry.async_get_or_create( + "light", + "hue", + "old", + config_entry=config_entry, + config_subentry_id=old_subentry_id, + device_id=device_entry.id, + ) + sub2_entity = entity_registry.async_get_or_create( + "light", + "hue", + "2", + config_entry=config_entry, + config_subentry_id="sub-2", + device_id=device_entry.id, + ) + + # Move the device to subentry sub-2 (an update, not a removal) + device_registry.async_update_device(device_entry.id, new_config_subentry_id="sub-2") + await hass.async_block_till_done() + + # The departing subentry's entity is removed; sub-2's entity is kept + assert not entity_registry.async_is_registered(old_entity.entity_id) + assert entity_registry.async_is_registered(sub2_entity.entity_id) async def test_remove_config_subentry_from_device_removes_entities( @@ -2918,7 +3026,7 @@ async def test_remove_config_subentry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config subentry is removed.""" + """Test that we remove entities tied to a device when its config subentry is removed.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -2940,27 +3048,15 @@ async def test_remove_config_subentry_from_device_removes_entities( ) config_entry_1.add_to_hass(hass) - # Create device with three config subentries - device_registry.async_get_or_create( + # A device belongs to a single config subentry + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, - } + assert device_entry.config_subentry_id == "mock-subentry-id-1" - # Create one entity entry for each config entry or subentry + # Entity tied to the device's subentry entry_1 = entity_registry.async_get_or_create( "light", "hue", @@ -2969,7 +3065,7 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-1", device_id=device_entry.id, ) - + # Entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", "hue", @@ -2978,22 +3074,11 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - entry_3 = entity_registry.async_get_or_create( - "sensor", - "device_tracker", - "6789", - config_entry=config_entry_1, - config_subentry_id=None, - device_id=device_entry.id, - ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the first config subentry from the device, the entity associated with it - # should be removed + # Removing the device's config subentry deletes the device; the entity tied to that + # subentry is removed, the entity tied to another subentry is detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, @@ -3001,55 +3086,18 @@ async def test_remove_config_subentry_from_device_removes_entities( ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the second config subentry from the device, the entity associated with it - # should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the third config subentry from the device, the entity associated with it - # (and the device itself) should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-2", - ) - await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_2.entity_id).device_id is None -@pytest.mark.parametrize( - ("subentries_in_device", "subentry_in_entity"), - [ - (["mock-subentry-id-1", "mock-subentry-id-2"], None), - ([None, "mock-subentry-id-2"], "mock-subentry-id-1"), - ], -) async def test_remove_config_subentry_from_device_removes_entities_2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - subentries_in_device: list[str | None], - subentry_in_entity: str | None, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config subentry.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -3067,95 +3115,49 @@ async def test_remove_config_subentry_from_device_removes_entities_2( title="Mock title", unique_id="test", ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-3", - subentry_type="test", - title="Mock title", - unique_id="test", - ), ], ) config_entry_1.add_to_hass(hass) - # Create device with two config subentries - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[0], - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[1], + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: set(subentries_in_device), - } - # Create an entity without config entry or subentry + # An entity without a config entry entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity for same config entry but subentry not in device + # An entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", - "some_helper", - "5678", - config_entry=config_entry_1, - config_subentry_id=subentry_in_entity, - device_id=device_entry.id, - ) - # Create an entity for same config entry but subentry not in device - entry_3 = entity_registry.async_get_or_create( - "light", - "some_helper", + "hue", "abcd", config_entry=config_entry_1, - config_subentry_id="mock-subentry-id-3", + config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3 assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the first config subentry from the device + # Removing the device's config subentry deletes the device; entities not tied to + # that subentry are kept but detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[0], - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the second config subentry from the device, this removes the device - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[1], + remove_config_subentry_id="mock-subentry-id-1", ) await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Check the device link is set to None assert entity_registry.async_get(entry_1.entity_id).device_id is None assert entity_registry.async_get(entry_2.entity_id).device_id is None - assert entity_registry.async_get(entry_3.entity_id).device_id is None async def test_update_device_race( @@ -3642,9 +3644,9 @@ async def test_resolve_entity_ids(entity_registry: er.EntityRegistry) -> None: er.async_validate_entity_ids(entity_registry, ["unknown_uuid"]) -def test_entity_registry_items() -> None: +async def test_entity_registry_items(hass: HomeAssistant) -> None: """Test the EntityRegistryItems container.""" - entities = er.EntityRegistryItems() + entities = er.EntityRegistryItems(hass) assert entities.get_entity_id(("a", "b", "c")) is None assert entities.get_entry("abc") is None @@ -5406,3 +5408,293 @@ async def test_subentry( config_subentry_id="mock-subentry-id-2-1", ) assert entry.config_subentry_id == "mock-subentry-id-2-1" + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Entities are moved to the split device matching their config entry.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + assert entity_registry.async_get("sensor.a").device_id == by_entry[entry_a.entry_id] + assert entity_registry.async_get("sensor.b").device_id == by_entry[entry_b.entry_id] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities_fallbacks( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity not exactly matching a split falls back by config entry, then first split.""" + entry_a = MockConfigEntry( + domain="domain_a", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-sub", + subentry_type="test", + title="t", + unique_id="u", + ) + ], + ) + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The split for entry_a is on the "mock-sub" subentry + device_store = _composite_device_storage(entry_a, entry_b) + device_store["data"]["devices"][0]["config_entries_subentries"] = { + entry_a.entry_id: ["mock-sub"], + entry_b.entry_id: [None], + } + hass_storage[dr.STORAGE_KEY] = device_store + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + # config entry matches a split, but the subentry does not + "entity_id": "sensor.sub", + "platform": "domain_a", + "unique_id": "sub", + "config_entry_id": entry_a.entry_id, + "config_subentry_id": None, + "device_id": COMPOSITE_ID, + }, + { + # no split matches the config entry (it has none) + "entity_id": "sensor.none", + "platform": "domain_a", + "unique_id": "none", + "config_entry_id": None, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + by_entry = {d.config_entry_id: d.id for d in splits} + # Subentry mismatch falls back to the split owning the entity's config entry + assert ( + entity_registry.async_get("sensor.sub").device_id == by_entry[entry_a.entry_id] + ) + # No matching config entry falls back to the first split + assert entity_registry.async_get("sensor.none").device_id in {d.id for d in splits} + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_entries_for_device_legacy_composite_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A legacy composite device id resolves to its split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + # The composite id is no longer a live device; its entities were repointed to splits + assert COMPOSITE_ID not in device_registry.devices + + # get_entries_for_device_id resolves the composite id to the split entities + assert { + entry.entity_id + for entry in entity_registry.entities.get_entries_for_device_id(COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # The public helper resolves the composite id via the device registry + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # Disabled entities are only included when requested, across the split devices + entity_registry.async_update_entity( + "sensor.b", disabled_by=er.RegistryEntryDisabler.USER + ) + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a"} + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, COMPOSITE_ID, include_disabled_entities=True + ) + } == {"sensor.a", "sensor.b"} + + # A live split device id returns just its own entity + splits = { + device.config_entry_id: device.id + for device in device_registry.async_get_devices_for_composite_device_id( + COMPOSITE_ID + ) + } + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, splits[entry_a.entry_id] + ) + } == {"sensor.a"} + + +async def test_async_entries_for_device_composite_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A pre-migration composite id resolves to the underlying devices' entities. + + Backwards compatibility for unmodified integrations: before the single-config-entry + rewrite a shared identifier resolved to one multi-config-entry device, so + async_entries_for_device(composite_id) returned all of that device's entities. After + the split, the composite's virtual id must resolve to the same union so a legacy + reference keeps working. + """ + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("itg1", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")} + ) + entity_1 = entity_registry.async_get_or_create( + "sensor", "itg1", "u1", config_entry=entry_1, device_id=device_1.id + ) + entity_2 = entity_registry.async_get_or_create( + "sensor", "itg2", "u2", config_entry=entry_2, device_id=device_2.id + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + assert old_id not in device_registry.devices + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, old_id) + } == {entity_1.entity_id, entity_2.entity_id} diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 640b2ff011af..7b6d713419ce 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -230,33 +230,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( set_source_entity_id_or_uuid: Mock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -267,10 +251,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -294,34 +274,18 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: - """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source_entity_removed handler is called when the source entity is removed.""" assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -331,9 +295,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the custom handler took over: the helper entity is left linked to the + # source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -357,21 +322,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity removed from the source device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Remove the source entity from the device @@ -381,9 +338,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -408,11 +365,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity is moved to another device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Create another device to move the source entity to source_device_2 = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, @@ -422,15 +374,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id not in source_device_2.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Move the source entity to another device @@ -442,11 +389,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is moved to the other device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id in source_device_2.config_entries + # Check that the helper entity is relinked to the other device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device_2.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -475,21 +420,13 @@ async def test_async_handle_source_entity_new_entity_id( set_source_entity_id_calls: int, ) -> None: """Test the source entity's entity ID is changed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Change the source entity's entity ID @@ -501,9 +438,9 @@ async def test_async_handle_source_entity_new_entity_id( assert len(async_unload_entry.mock_calls) == unload_calls assert len(set_source_entity_id_or_uuid.mock_calls) == set_source_entity_id_calls - # Check that the helper config is still in the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the helper entity is still linked to the source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -520,13 +457,25 @@ async def test_async_remove_helper_config_entry_from_source_device( entity_registry: er.EntityRegistry, helper_config_entry: MockConfigEntry, helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, source_device: dr.DeviceEntry, ) -> None: """Test removing the helper config entry from the source device.""" - # Add the helper config entry to the source device + # In the single-owner model the migration helper only acts when the helper config + # entry owns the source device. Move the source device to the helper config entry + # and record a pending move back to the source config entry, so removing the helper + # config entry hands the device back to the source config entry instead of deleting + # it. device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id + source_device.id, + add_config_entry_id=helper_config_entry.entry_id, + remove_config_entry_id=source_config_entry.entry_id, ) + device_registry.async_update_device( + source_device.id, add_config_entry_id=source_config_entry.entry_id + ) + source_device = device_registry.async_get(source_device.id) + assert source_device.config_entries == {helper_config_entry.entry_id} # Create a helper entity entry, not connected to the source device extra_helper_entity_entry = entity_registry.async_get_or_create( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 29c31d494777..e9e459a4d60a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -163,10 +163,18 @@ def floor_area_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) mock_device_registry( hass, @@ -330,13 +338,21 @@ def label_mock(hass: HomeAssistant) -> None: }, ) - device_has_label1 = dr.DeviceEntry(labels={"label1"}) - device_has_label2 = dr.DeviceEntry(labels={"label2"}) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label1"} + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label2"} + ) device_has_labels = dr.DeviceEntry( - labels={"label1", "label2"}, area_id=area_with_labels.id + config_entry_id="mock-config-entry", + labels={"label1", "label2"}, + area_id=area_with_labels.id, ) device_no_labels = dr.DeviceEntry( - id="device-no-labels", area_id=area_without_labels.id + config_entry_id="mock-config-entry", + id="device-no-labels", + area_id=area_without_labels.id, ) mock_device_registry( @@ -2491,7 +2507,10 @@ async def test_async_extract_entities_warn_referenced( async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: """Test we can find devices that have no entities.""" - device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) + device_no_entities = dr.DeviceEntry( + config_entry_id="abc", + id="device-no-entities", + ) call = ServiceCall( hass, diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 9d72951868ed..93b9adf7a67d 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Mapping +from typing import Any import pytest @@ -109,13 +110,30 @@ def registries_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") - device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"}) - device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"}) + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-test-area", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-diff-area", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label1-id", + labels={"label1"}, + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label2-id", + labels={"label2"}, + ) device_has_labels = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-has-labels-id", labels={"label1", "label2"}, area_id=area_with_labels.id, @@ -988,3 +1006,94 @@ async def test_async_track_target_selector_no_on_entities_update( assert len(events) == 1 unsub() + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_target_trickle_down_to_splits( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Targeting the legacy id reaches the split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"device_id": COMPOSITE_ID}) + ) + assert COMPOSITE_ID not in selected.missing_devices + splits = { + d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + # The composite id resolves to its splits only; it is not itself referenced (it is not + # a real device), so a device-id consumer does not act on the same device twice. + assert selected.referenced_devices == splits + assert COMPOSITE_ID not in selected.referenced_devices + assert selected.indirectly_referenced == {"sensor.a", "sensor.b"} diff --git a/tests/syrupy.py b/tests/syrupy.py index 253ebea3f247..09d5ea353f9c 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -36,6 +36,17 @@ ANY = _ANY() __all__ = ["HomeAssistantSnapshotExtension"] +# DeviceEntry attributes that are internal bookkeeping and should not appear in snapshots. +# Underscore attributes (_cache, _suggested_area and the transient _pending_move / +# _composite_subentries) are excluded separately. The composite-device migration +# attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + class AreaRegistryEntrySnapshot(dict): """Tiny wrapper to represent an area registry entry in snapshots.""" @@ -150,21 +161,31 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): cls, data: dr.DeviceEntry ) -> SerializableData: """Prepare a Home Assistant device registry entry for serialization.""" + # Exclude internal attributes (caches, transient move state, and the + # composite-device migration bookkeeping) from the snapshot serialized = DeviceRegistryEntrySnapshot( - attrs.asdict(data) - | { - "config_entries": ANY, - "config_entries_subentries": ANY, - "id": ANY, - } + attr.asdict( + data, + retain_collection_types=True, + filter=lambda attribute, _: ( + not attribute.name.startswith("_") + and attribute.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ), + ) + | {"id": ANY} ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - if serialized["primary_config_entry"] is not None: - serialized["primary_config_entry"] = ANY - serialized.pop("_cache") - # This can be removed when suggested_area is removed from DeviceEntry - serialized.pop("_suggested_area") + + # Remove single config entry and subentry ids to not break snapshots + serialized.pop("config_entry_id") + serialized.pop("config_subentry_id") + + # Set removed composite device attributes to ANY to not break snapshots + serialized["config_entries"] = ANY + serialized["config_entries_subentries"] = ANY + serialized["primary_config_entry"] = ANY + return cls._remove_created_and_modified_at(serialized) @classmethod diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 62ebc4de916f..d2b672d500be 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6260,6 +6260,57 @@ async def test_loading_old_data( assert entry.pref_disable_new_entities is True +async def test_async_initialize_sets_event_with_empty_store( + hass: HomeAssistant, +) -> None: + """The initialized event is set when there is no stored data to load. + + The device registry waits on this event during its own load. + """ + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + with patch.object(manager._store, "async_load", return_value=None): + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert manager.async_entries() == [] + + +async def test_async_initialize_sets_event_with_existing_store( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The initialized event is set when loading an existing store. + + The device registry waits on this event during its own load. + """ + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "data": { + "entries": [ + { + "version": 5, + "domain": "my_domain", + "entry_id": "mock-id", + "data": {"my": "data"}, + "source": "user", + "title": "Mock title", + "system_options": {"disable_new_entities": True}, + } + ] + }, + } + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert len(manager.async_entries()) == 1 + + async def test_deprecated_disabled_by_str_ctor() -> None: """Test deprecated str disabled_by constructor enumizes and logs a warning.""" with pytest.raises( From d33d6d9aacd2ec1cdf256b4659d7327997f80ffb Mon Sep 17 00:00:00 2001 From: Manu Date: Thu, 16 Jul 2026 23:25:11 +0200 Subject: [PATCH 662/707] Fix missing field in Steam integration (#176632) --- homeassistant/components/steam_online/coordinator.py | 1 + tests/components/steam_online/fixtures/GetPlayerSummaries.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index ea2f37f11f52..bc929103df13 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -45,6 +45,7 @@ class PlayerData: loccityid: int | None = None gameextrainfo: str | None = None gameid: str | None = None + lobbysteamid: str | None = None level: int | None = None diff --git a/tests/components/steam_online/fixtures/GetPlayerSummaries.json b/tests/components/steam_online/fixtures/GetPlayerSummaries.json index d3aa4bf87dc0..c81c15a95065 100644 --- a/tests/components/steam_online/fixtures/GetPlayerSummaries.json +++ b/tests/components/steam_online/fixtures/GetPlayerSummaries.json @@ -19,7 +19,8 @@ "realname": "John Dough", "personastateflags": 0, "gameextrainfo": "The Witcher: Enhanced Edition", - "gameid": "20900" + "gameid": "20900", + "lobbysteamid": "109775243377594361" }, { "steamid": "12345678912345678", From d60e14d9af8472458e052c435f4aa96bbd4151e8 Mon Sep 17 00:00:00 2001 From: Nikolai Rahimi Date: Fri, 17 Jul 2026 02:28:06 -0400 Subject: [PATCH 663/707] Bump mitsubishi-comfort to 0.5.0 (#176642) Co-authored-by: Nikolai Rahimi --- homeassistant/components/mitsubishi_comfort/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json index c93ca805da6a..c4935aa408cb 100644 --- a/homeassistant/components/mitsubishi_comfort/manifest.json +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["mitsubishi-comfort==0.3.2"] + "requirements": ["mitsubishi-comfort==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e0f940c1b7a6..c525d73beb49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1595,7 +1595,7 @@ millheater==0.14.1 minio==7.1.12 # homeassistant.components.mitsubishi_comfort -mitsubishi-comfort==0.3.2 +mitsubishi-comfort==0.5.0 # homeassistant.components.moat moat-ble==0.1.1 From 1fd19f6a7f45941ec4169330ccbc961b95d18251 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 17 Jul 2026 08:28:40 +0200 Subject: [PATCH 664/707] Add translation strings for KNX project view devices tab (#176636) --- homeassistant/components/knx/strings.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 6cf052bfb633..ddc1f06465c7 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1025,7 +1025,18 @@ "title": "Information" }, "project": { - "description": "Inspect imported group addresses", + "description": "Inspect imported project", + "devices": { + "channels": "Channels", + "group_objects": "Group objects", + "lines": "Lines", + "locations": "Locations", + "not_found": "No devices found in project data.", + "title": "Devices" + }, + "group_addresses": { + "title": "[%key:component::knx::config_panel::common::group_addresses%]" + }, "title": "Project" }, "selectors": { From a1e1383b4cf80728d0efb3d73257272693651933 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 17 Jul 2026 09:04:47 +0200 Subject: [PATCH 665/707] Bump ZHA to 2.0.1 (#176643) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 180a95286afe..7112914208f5 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==2.0.0", "zha-quirks==2.1.1"], + "requirements": ["zha==2.0.1", "zha-quirks==2.1.1"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index c525d73beb49..5a83382402a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3469,7 +3469,7 @@ zeversolar==0.3.2 zha-quirks==2.1.1 # homeassistant.components.zha -zha==2.0.0 +zha==2.0.1 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 From 6bea721159286bfa2e1a3ff19338fa8c6a7df2f1 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 17 Jul 2026 09:11:47 +0200 Subject: [PATCH 666/707] Refactor exception in config flow of Tado (#176651) --- homeassistant/components/tado/config_flow.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index ddde26ae083f..6d22cb777f22 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -18,7 +18,6 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( @@ -223,9 +222,9 @@ class OptionsFlowHandler(OptionsFlow): return self.async_show_form(step_id="init", data_schema=data_schema) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class TadoRateLimitExceeded(HomeAssistantError): +class TadoRateLimitExceeded(Exception): """Error to indicate Tado API rate limit exceeded.""" From 9ef5840d04c1c4ca7b1bd0c77de8fc39c371102d Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 17 Jul 2026 09:27:40 +0200 Subject: [PATCH 667/707] Fix open/close for RTSGeneric covers in Overkiz (#176626) --- homeassistant/components/overkiz/cover.py | 4 +- .../setup/cloud_somfy_connexoon_rts_asia.json | 67 +++++++++++++++++++ .../overkiz/snapshots/test_cover.ambr | 53 +++++++++++++++ tests/components/overkiz/test_cover.py | 12 ++++ 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 25a872a00e90..34397ff794e2 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -354,8 +354,8 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [ # uiClass is Generic (not mapped to cover as this is a Generic device class) OverkizCoverDescription( key=UIWidget.RTS_GENERIC, - open_command=OverkizCommand.OPEN, - close_command=OverkizCommand.CLOSE, + open_command=OverkizCommand.UP, + close_command=OverkizCommand.DOWN, stop_command=OverkizCommand.STOP, ), ## diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json index 9f6ed2f2a18e..6d18e6b9eecd 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json @@ -544,6 +544,73 @@ "type": 1, "oid": "c198bcdd-8b8b-4dc6-a2b0-f86f7dc7c001", "uiClass": "VenetianBlind" + }, + { + "creationTime": 1613676720000, + "lastUpdateTime": 1613676720000, + "label": "Living Room Screen", + "deviceURL": "rts://1234-1234-6362/16718220", + "shortcut": false, + "controllableName": "rts:GenericRTSComponent", + "definition": { + "commands": [ + { + "commandName": "down", + "nparams": 1 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "rest", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 1 + }, + { + "commandName": "test", + "nparams": 0 + }, + { + "commandName": "up", + "nparams": 1 + }, + { + "commandName": "openConfiguration", + "nparams": 1 + } + ], + "states": [], + "dataProperties": [ + { + "value": "0", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "RTSGeneric", + "uiProfiles": ["UpDown"], + "uiClass": "Generic", + "qualifiedName": "rts:GenericRTSComponent", + "type": "ACTUATOR" + }, + "states": [], + "attributes": [ + { + "name": "rts:diy", + "type": 6, + "value": true + } + ], + "available": true, + "enabled": true, + "placeOID": "6133b4a0-f514-4553-b635-d1b7beb7e7b2", + "widget": "RTSGeneric", + "type": 1, + "oid": "97f85fd1-53f7-4cdf-8c73-9b2c172dbd62", + "uiClass": "Generic" } ], "zones": [], diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index e8b5e5d604a0..f2bf58ab2ec1 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -485,6 +485,59 @@ 'state': 'unknown', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_living_room_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.palm_court_living_room_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'rts://1234-1234-6362/16718220', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_living_room_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'Living Room Screen', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.palm_court_living_room_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_office_venetian_blind-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 76633f29d74c..eaf0d21845df 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -122,6 +122,12 @@ UP_DOWN_SHEER_SCREEN = FixtureDevice( "rts://1234-1234-6362/16753206", "cover.palm_court_kitchen_sheer_screen", ) +# RTSGeneric only exposes raw up/down/stop commands (no open/close) +RTS_GENERIC = FixtureDevice( + "setup/cloud_somfy_connexoon_rts_asia.json", + "rts://1234-1234-6362/16718220", + "cover.palm_court_living_room_screen", +) DISCRETE_GARAGE_DOOR = FixtureDevice( "setup/local_somfy_tahoma_v2_europe.json", "io://1234-5678-3293/12745774", @@ -276,6 +282,7 @@ async def test_cover_entities_snapshot( ), (UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (UP_DOWN_SHEER_SCREEN, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + (RTS_GENERIC, SERVICE_OPEN_COVER, "up", None, CoverState.OPENING), ( DYNAMIC_VENETIAN_BLIND, SERVICE_OPEN_COVER, @@ -334,6 +341,7 @@ async def test_cover_entities_snapshot( CoverState.CLOSING, ), (UP_DOWN_SHEER_SCREEN, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + (RTS_GENERIC, SERVICE_CLOSE_COVER, "down", None, CoverState.CLOSING), ( DYNAMIC_VENETIAN_BLIND, SERVICE_CLOSE_COVER, @@ -396,6 +404,7 @@ async def test_cover_entities_snapshot( ), (UP_DOWN_VENETIAN_BLIND, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), (UP_DOWN_SHEER_SCREEN, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), + (RTS_GENERIC, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), ( UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER_TILT, @@ -459,6 +468,7 @@ async def test_cover_entities_snapshot( "open-tilt-only-venetian-blind", "open-venetian-blind-rts", "open-sheer-screen-rts", + "open-rts-generic", "open-dynamic-venetian-blind", "close-roller-shutter", "close-awning", @@ -479,6 +489,7 @@ async def test_cover_entities_snapshot( "close-tilt-only-venetian-blind", "close-venetian-blind-rts", "close-sheer-screen-rts", + "close-rts-generic", "close-dynamic-venetian-blind", "stop-roller-shutter", "stop-awning", @@ -499,6 +510,7 @@ async def test_cover_entities_snapshot( "stop-tilt-tilt-only-venetian-blind", "stop-venetian-blind-rts", "stop-sheer-screen-rts", + "stop-rts-generic", "open-tilt-venetian-blind-rts", "close-tilt-venetian-blind-rts", "stop-tilt-venetian-blind-rts", From 32ce6e222094492d127b82f19264591a6661e4cd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 09:36:47 +0200 Subject: [PATCH 668/707] Don't allow binding an entity to a composite device_id (#176650) --- homeassistant/helpers/entity_registry.py | 2 +- tests/helpers/test_entity_registry.py | 41 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 9d3cd41e329f..03a42c7cf5b9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1131,7 +1131,7 @@ def _validate_item( ) if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) - if not device_registry.async_get(device_id): + if device_id not in device_registry.devices: raise ValueError(f"Device {device_id} does not exist") if ( disabled_by diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 532a594d33b4..b69752d29603 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -3705,6 +3705,47 @@ async def test_device_does_not_exist(entity_registry: er.EntityRegistry) -> None entity_registry.async_update_entity(entity_id, device_id="blah") +async def test_composite_device_id_not_allowed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an entity cannot be linked to a pre-migration composite device id. + + async_get resolves a composite id to a synthesized read-only device, but it is not a + real device, so linking an entity to it must be rejected. + """ + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("itg1", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + # The composite id resolves to a synthesized device, but is not a real registry entry + assert device_registry.async_get(old_id) is not None + assert old_id not in device_registry.devices + + match = f"Device {old_id} does not exist" + with pytest.raises(ValueError, match=match): + entity_registry.async_get_or_create("light", "hue", "1234", device_id=old_id) + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError, match=match): + entity_registry.async_update_entity(entity_id, device_id=old_id) + + async def test_disabled_by_str_not_allowed(entity_registry: er.EntityRegistry) -> None: """Test we need to pass disabled by type.""" with pytest.raises(ValueError): From 201ac314a4b329c623d36e635cf39466013c7bde Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 09:39:13 +0200 Subject: [PATCH 669/707] Align DeviceRegistryEntrySnapshot with the device registry model (#176654) --- .../components/acaia/snapshots/test_init.ambr | 5 +- .../airgradient/snapshots/test_init.ambr | 10 +- .../airobot/snapshots/test_init.ambr | 5 +- .../airvisual_pro/snapshots/test_init.ambr | 5 +- .../alexa_devices/snapshots/test_init.ambr | 5 +- .../anthemav/snapshots/test_init.ambr | 5 +- .../aosmith/snapshots/test_device.ambr | 5 +- .../apcupsd/snapshots/test_init.ambr | 20 +- .../aprilaire/snapshots/test_init.ambr | 5 +- .../aqvify/snapshots/test_init.ambr | 10 +- .../asuswrt/snapshots/test_init.ambr | 5 +- .../august/snapshots/test_binary_sensor.ambr | 5 +- .../august/snapshots/test_lock.ambr | 5 +- .../aws_s3/snapshots/test_sensor.ambr | 5 +- tests/components/axis/snapshots/test_hub.ambr | 10 +- .../cambridge_audio/snapshots/test_init.ambr | 5 +- .../casper_glow/snapshots/test_init.ambr | 5 +- .../chess_com/snapshots/test_init.ambr | 5 +- .../components/deconz/snapshots/test_hub.ambr | 5 +- .../snapshots/test_init.ambr | 15 +- .../components/eafm/snapshots/test_init.ambr | 5 +- .../earn_e_p1/snapshots/test_init.ambr | 5 +- .../ecovacs/snapshots/test_init.ambr | 5 +- .../egauge/snapshots/test_sensor.ambr | 5 +- .../electrasmart/snapshots/test_init.ambr | 5 +- .../elgato/snapshots/test_button.ambr | 10 +- .../elgato/snapshots/test_light.ambr | 15 +- .../elgato/snapshots/test_sensor.ambr | 25 +- .../elgato/snapshots/test_switch.ambr | 10 +- .../essent/snapshots/test_init.ambr | 5 +- .../snapshots/test_init.ambr | 5 +- tests/components/flo/snapshots/test_init.ambr | 10 +- .../snapshots/test_init.ambr | 5 +- .../fumis/snapshots/test_climate.ambr | 5 +- .../snapshots/test_init.ambr | 10 +- .../gentex_homelink/snapshots/test_init.ambr | 5 +- .../google_drive/snapshots/test_sensor.ambr | 5 +- .../snapshots/test_init.ambr | 20 +- .../growatt_server/snapshots/test_init.ambr | 10 +- .../components/homee/snapshots/test_init.ambr | 10 +- .../snapshots/test_init.ambr | 580 +++--- .../homewizard/snapshots/test_button.ambr | 5 +- .../homewizard/snapshots/test_number.ambr | 10 +- .../homewizard/snapshots/test_select.ambr | 5 +- .../homewizard/snapshots/test_sensor.ambr | 1430 ++++++--------- .../homewizard/snapshots/test_switch.ambr | 55 +- .../snapshots/test_init.ambr | 5 +- .../snapshots/test_init.ambr | 5 +- .../components/huum/snapshots/test_init.ambr | 5 +- .../ialarm/snapshots/test_init.ambr | 5 +- .../intelliclima/snapshots/test_fan.ambr | 5 +- .../intelliclima/snapshots/test_select.ambr | 5 +- .../intelliclima/snapshots/test_sensor.ambr | 5 +- .../iotty/snapshots/test_switch.ambr | 5 +- .../ista_ecotrend/snapshots/test_init.ambr | 10 +- .../ituran/snapshots/test_init.ambr | 5 +- .../jvc_projector/snapshots/test_init.ambr | 5 +- .../kiosker/snapshots/test_init.ambr | 5 +- .../kitchen_sink/snapshots/test_switch.ambr | 20 +- .../lamarzocco/snapshots/test_bluetooth.ambr | 10 +- .../lamarzocco/snapshots/test_init.ambr | 5 +- .../lektrico/snapshots/test_init.ambr | 5 +- .../lichess/snapshots/test_init.ambr | 5 +- .../lojack/snapshots/test_init.ambr | 5 +- .../mastodon/snapshots/test_init.ambr | 5 +- .../mealie/snapshots/test_init.ambr | 5 +- .../meater/snapshots/test_init.ambr | 5 +- .../melnor/snapshots/test_init.ambr | 5 +- .../components/miele/snapshots/test_init.ambr | 5 +- .../mystrom/snapshots/test_init.ambr | 5 +- .../myuplink/snapshots/test_init.ambr | 15 +- .../snapshots/test_init.ambr | 10 +- .../netatmo/snapshots/test_init.ambr | 200 +-- .../netgear_lte/snapshots/test_init.ambr | 5 +- .../nrgkick/snapshots/test_init.ambr | 5 +- .../nyt_games/snapshots/test_init.ambr | 15 +- .../components/ohme/snapshots/test_init.ambr | 5 +- .../ondilo_ico/snapshots/test_init.ambr | 10 +- .../onedrive/snapshots/test_init.ambr | 5 +- .../onewire/snapshots/test_init.ambr | 120 +- .../snapshots/test_init.ambr | 10 +- .../overseerr/snapshots/test_init.ambr | 5 +- .../palazzetti/snapshots/test_init.ambr | 5 +- .../peblar/snapshots/test_init.ambr | 5 +- .../pooldose/snapshots/test_init.ambr | 5 +- .../portainer/snapshots/test_init.ambr | 60 +- .../components/prana/snapshots/test_init.ambr | 5 +- .../ps4/snapshots/test_media_player.ambr | 5 +- .../rabbitair/snapshots/test_init.ambr | 5 +- .../rainbird/snapshots/test_init.ambr | 5 +- .../rainforest_raven/snapshots/test_init.ambr | 5 +- .../renault/snapshots/test_init.ambr | 30 +- .../renson/snapshots/test_init.ambr | 5 +- .../components/ring/snapshots/test_init.ambr | 5 +- .../components/rova/snapshots/test_init.ambr | 5 +- .../russound_rio/snapshots/test_init.ambr | 5 +- .../samsungtv/snapshots/test_init.ambr | 15 +- .../snapshots/test_alarm_control_panel.ambr | 5 +- .../snapshots/test_binary_sensor.ambr | 10 +- .../satel_integra/snapshots/test_init.ambr | 5 +- .../satel_integra/snapshots/test_sensor.ambr | 5 +- .../satel_integra/snapshots/test_switch.ambr | 5 +- .../saunum/snapshots/test_init.ambr | 5 +- .../schlage/snapshots/test_init.ambr | 5 +- .../scrape/snapshots/test_init.ambr | 5 +- .../sensibo/snapshots/test_entity.ambr | 20 +- .../sfr_box/snapshots/test_init.ambr | 5 +- .../slide_local/snapshots/test_init.ambr | 5 +- .../smartthings/snapshots/test_init.ambr | 475 ++--- .../smarty/snapshots/test_init.ambr | 5 +- .../smlight/snapshots/test_init.ambr | 5 +- .../components/snooz/snapshots/test_init.ambr | 5 +- .../squeezebox/snapshots/test_init.ambr | 10 +- .../steam_online/snapshots/test_init.ambr | 5 +- .../sunricher_dali/snapshots/test_init.ambr | 25 +- .../snapshots/test_binary_sensor.ambr | 10 +- .../tailwind/snapshots/test_button.ambr | 5 +- .../tailwind/snapshots/test_cover.ambr | 10 +- .../tailwind/snapshots/test_number.ambr | 5 +- .../components/tedee/snapshots/test_init.ambr | 10 +- .../components/tedee/snapshots/test_lock.ambr | 5 +- .../teltonika/snapshots/test_init.ambr | 5 +- .../tesla_fleet/snapshots/test_init.ambr | 20 +- .../teslemetry/snapshots/test_init.ambr | 20 +- .../components/tile/snapshots/test_init.ambr | 5 +- .../togrill/snapshots/test_init.ambr | 5 +- .../tplink/snapshots/test_binary_sensor.ambr | 5 +- .../tplink/snapshots/test_button.ambr | 5 +- .../tplink/snapshots/test_camera.ambr | 5 +- .../tplink/snapshots/test_climate.ambr | 5 +- .../components/tplink/snapshots/test_fan.ambr | 5 +- .../tplink/snapshots/test_number.ambr | 5 +- .../tplink/snapshots/test_select.ambr | 5 +- .../tplink/snapshots/test_sensor.ambr | 5 +- .../tplink/snapshots/test_siren.ambr | 5 +- .../tplink/snapshots/test_switch.ambr | 5 +- .../tplink/snapshots/test_vacuum.ambr | 5 +- .../components/trmnl/snapshots/test_init.ambr | 5 +- .../components/tuya/snapshots/test_init.ambr | 1585 +++++++---------- .../twentemilieu/snapshots/test_calendar.ambr | 5 +- .../twentemilieu/snapshots/test_sensor.ambr | 25 +- .../unifiprotect/snapshots/test_init.ambr | 5 +- .../uptime/snapshots/test_sensor.ambr | 5 +- .../velbus/snapshots/test_init.ambr | 50 +- .../vesync/snapshots/test_binary_sensor.ambr | 80 +- .../components/vesync/snapshots/test_fan.ambr | 80 +- .../vesync/snapshots/test_humidifier.ambr | 80 +- .../vesync/snapshots/test_light.ambr | 80 +- .../vesync/snapshots/test_sensor.ambr | 80 +- .../vesync/snapshots/test_switch.ambr | 80 +- .../vesync/snapshots/test_update.ambr | 80 +- .../components/vilfo/snapshots/test_init.ambr | 10 +- .../webostv/snapshots/test_media_player.ambr | 5 +- .../whois/snapshots/test_sensor.ambr | 50 +- .../withings/snapshots/test_init.ambr | 10 +- .../wled/snapshots/test_button.ambr | 5 +- .../wled/snapshots/test_number.ambr | 10 +- .../wmspro/snapshots/test_cover.ambr | 5 +- .../wmspro/snapshots/test_init.ambr | 60 +- .../wmspro/snapshots/test_light.ambr | 5 +- .../wmspro/snapshots/test_scene.ambr | 5 +- .../wmspro/snapshots/test_switch.ambr | 5 +- .../wolflink/snapshots/test_sensor.ambr | 5 +- .../xthings_cloud/snapshots/test_init.ambr | 30 +- .../yale/snapshots/test_binary_sensor.ambr | 5 +- .../components/yale/snapshots/test_lock.ambr | 5 +- .../zinvolt/snapshots/test_init.ambr | 10 +- .../snapshots/test_entity_platform.ambr | 10 +- tests/syrupy.py | 10 +- 169 files changed, 2542 insertions(+), 3818 deletions(-) diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index 9e3112606936..8ab2584f2948 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'LUNAR-DDEEFF', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 2a1e3dcc7fd7..b5af12e65a2e 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[indoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'I-9PSL', 'name': 'Airgradient', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '84fce612f5b8', 'sw_version': '3.1.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device_info[outdoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': 'O-1PPT', 'name': 'Airgradient', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '84fce612f5b8', 'sw_version': '3.1.1', 'via_device_id': None, diff --git a/tests/components/airobot/snapshots/test_init.ambr b/tests/components/airobot/snapshots/test_init.ambr index b7e2957b834c..7e62c1dff0c2 100644 --- a/tests/components/airobot/snapshots/test_init.ambr +++ b/tests/components/airobot/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'TE1', 'name': 'Test Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.44', 'via_device_id': None, diff --git a/tests/components/airvisual_pro/snapshots/test_init.ambr b/tests/components/airvisual_pro/snapshots/test_init.ambr index e2fef8910baa..d0bf6ac327d8 100644 --- a/tests/components/airvisual_pro/snapshots/test_init.ambr +++ b/tests/components/airvisual_pro/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1826', 'via_device_id': None, diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr index e4ae777da32b..ded6fc40e291 100644 --- a/tests/components/alexa_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'echo', 'name': 'Echo Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'echo_test_serial_number', 'sw_version': 'echo_test_software_version', 'via_device_id': None, diff --git a/tests/components/anthemav/snapshots/test_init.ambr b/tests/components/anthemav/snapshots/test_init.ambr index 1bd187b1c9fb..ecda7f5df8af 100644 --- a/tests/components/anthemav/snapshots/test_init.ambr +++ b/tests/components/anthemav/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Anthem AV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index 057619a02463..ec7af18eef8f 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': 'basement', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'My water heater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'serial', 'sw_version': '2.14', 'via_device_id': None, diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr index 3309d384ec75..e51ed8bfad07 100644 --- a/tests/components/apcupsd/snapshots/test_init.ambr +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_async_setup_entry[mock_request_status0-mocked-config-entry-id][device_MyUPS_XXXXXXXXXXXX] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'MyUPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXXXXXXXXXX', 'sw_version': '3.14.14 (31 May 2016) unknown', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_async_setup_entry[mock_request_status1-mocked-config-entry-id][device_APC UPS_XXXX] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXX', 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_async_setup_entry[mock_request_status2-mocked-config-entry-id][device_APC UPS_] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_async_setup_entry[mock_request_status3-mocked-config-entry-id][device_APC UPS_Blank] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/aprilaire/snapshots/test_init.ambr b/tests/components/aprilaire/snapshots/test_init.ambr index e4fb26e5272c..96711abda099 100644 --- a/tests/components/aprilaire/snapshots/test_init.ambr +++ b/tests/components/aprilaire/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Aprilaire', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.05', 'via_device_id': None, diff --git a/tests/components/aqvify/snapshots/test_init.ambr b/tests/components/aqvify/snapshots/test_init.ambr index fe3d2c055820..1cef36dab3f8 100644 --- a/tests/components/aqvify/snapshots/test_init.ambr +++ b/tests/components/aqvify/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://app.aqvify.com', 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'Device 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'DeviceKey_1', 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://app.aqvify.com', 'connections': set({ }), @@ -54,7 +53,6 @@ 'model_id': None, 'name': 'Device 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'DeviceKey_2', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/asuswrt/snapshots/test_init.ambr b/tests/components/asuswrt/snapshots/test_init.ambr index 6b344d260c39..8f0901e3a5ad 100644 --- a/tests/components/asuswrt/snapshots/test_init.ambr +++ b/tests/components/asuswrt/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://myrouter.asuswrt.com:80', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'myrouter.asuswrt.com', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'FAKE_FIRMWARE', 'via_device_id': None, diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index 9d94ae9ffdc6..456a4468708d 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -2,8 +2,8 @@ # name: test_doorbell_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.august.com', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'tmt100 Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index 8af45cae68c8..a9f1292c9b43 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.august.com', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'online_with_doorsense Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, diff --git a/tests/components/aws_s3/snapshots/test_sensor.ambr b/tests/components/aws_s3/snapshots/test_sensor.ambr index ed0d8379e987..09fb233a6af9 100644 --- a/tests/components/aws_s3/snapshots/test_sensor.ambr +++ b/tests/components/aws_s3/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensor.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Bucket test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index 663c52dd36c4..20531509f452 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_entry[api_discovery_items0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', 'sw_version': '9.10.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device_registry_entry[api_discovery_items1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', 'sw_version': '9.80.1', 'via_device_id': None, diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 226426353755..83dec59d147b 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.20.218', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Cambridge Audio CXNv2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0020c2d8', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/casper_glow/snapshots/test_init.ambr b/tests/components/casper_glow/snapshots/test_init.ambr index 235ab505621d..5ffd48e71d33 100644 --- a/tests/components/casper_glow/snapshots/test_init.ambr +++ b/tests/components/casper_glow/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': 'G01', 'name': 'Jar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/chess_com/snapshots/test_init.ambr b/tests/components/chess_com/snapshots/test_init.ambr index 32d14022d105..2097ba150e13 100644 --- a/tests/components/chess_com/snapshots/test_init.ambr +++ b/tests/components/chess_com/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Joost', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index 884ce49edb69..829e3da21da9 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'deCONZ mock gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 69cf0adba2b5..d4539a2dfdd1 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device[mock_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device[mock_ipv6_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://[2001:db8::1]', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, @@ -72,8 +70,8 @@ # name: test_device[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ }), @@ -94,7 +92,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, diff --git a/tests/components/eafm/snapshots/test_init.ambr b/tests/components/eafm/snapshots/test_init.ambr index 39a5978315c8..eb58e3e1be52 100644 --- a/tests/components/eafm/snapshots/test_init.ambr +++ b/tests/components/eafm/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'My station Water Level Stage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/earn_e_p1/snapshots/test_init.ambr b/tests/components/earn_e_p1/snapshots/test_init.ambr index 9e04b5e3a66b..8279e82605b9 100644 --- a/tests/components/earn_e_p1/snapshots/test_init.ambr +++ b/tests/components/earn_e_p1/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'EARN-E P1 Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'E0012345678901234', 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index 0e847da73ad7..2b20d774c0a3 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices_in_dr[E1234567890000000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'yna5xi', 'name': 'Ozmo 950', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'E1234567890000000001', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/egauge/snapshots/test_sensor.ambr b/tests/components/egauge/snapshots/test_sensor.ambr index d9753970cd96..aaffbdacb28f 100644 --- a/tests/components/egauge/snapshots/test_sensor.ambr +++ b/tests/components/egauge/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors.12 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'egauge-home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ABC123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/electrasmart/snapshots/test_init.ambr b/tests/components/electrasmart/snapshots/test_init.ambr index 97b1d33f77f7..0a6a6bea0920 100644 --- a/tests/components/electrasmart/snapshots/test_init.ambr +++ b/tests/components/electrasmart/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index a7e18e93b641..c255e649e531 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_buttons[button.frenck_identify-identify-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -139,8 +138,8 @@ # name: test_buttons[button.frenck_restart-restart-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -165,7 +164,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 18f8e78da416..97fa93346ef4 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -80,8 +80,8 @@ # name: test_light_state_temperature[key-light-state].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -106,7 +106,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -195,8 +194,8 @@ # name: test_light_state_temperature[light-strip-state-color-temperature].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -221,7 +220,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -310,8 +308,8 @@ # name: test_light_state_temperature[light-strip-state].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -336,7 +334,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index cd079124f2a9..4e0ae5d822ee 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -60,8 +60,8 @@ # name: test_sensors[sensor.frenck_battery-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -86,7 +86,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -156,8 +155,8 @@ # name: test_sensors[sensor.frenck_battery_voltage-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -182,7 +181,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -252,8 +250,8 @@ # name: test_sensors[sensor.frenck_charging_current-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -278,7 +276,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -345,8 +342,8 @@ # name: test_sensors[sensor.frenck_charging_power-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -371,7 +368,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -441,8 +437,8 @@ # name: test_sensors[sensor.frenck_charging_voltage-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -467,7 +463,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 71b5c3dc3d44..4d48cc9bb37d 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_switches[switch.frenck_energy_saving-energy_saving-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -78,7 +78,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -137,8 +136,8 @@ # name: test_switches[switch.frenck_studio_mode-battery_bypass-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -163,7 +162,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/essent/snapshots/test_init.ambr b/tests/components/essent/snapshots/test_init.ambr index de1cc10d4f1f..c0bef2d727ae 100644 --- a/tests/components/essent/snapshots/test_init.ambr +++ b/tests/components/essent/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Essent', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/eurotronic_cometblue/snapshots/test_init.ambr b/tests/components/eurotronic_cometblue/snapshots/test_init.ambr index e7a6d8a3ebdc..79c9b95cd142 100644 --- a/tests/components/eurotronic_cometblue/snapshots/test_init.ambr +++ b/tests/components/eurotronic_cometblue/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Comet Blue aa:bb:cc:dd:ee:ff', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0.10', 'via_device_id': None, diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr index 6a242c4d2cec..7e4dc3e64f6b 100644 --- a/tests/components/flo/snapshots/test_init.ambr +++ b/tests/components/flo/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Smart water shutoff', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '6.1.1', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Kitchen sink', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111112', 'sw_version': '1.1.15', 'via_device_id': None, diff --git a/tests/components/fressnapf_tracker/snapshots/test_init.ambr b/tests/components/fressnapf_tracker/snapshots/test_init.ambr index 59d50fa4ce13..721deab34c45 100644 --- a/tests/components/fressnapf_tracker/snapshots/test_init.ambr +++ b/tests/components/fressnapf_tracker/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_state_entity_device_snapshots[Fluffy-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Fluffy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ABC123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/fumis/snapshots/test_climate.ambr b/tests/components/fumis/snapshots/test_climate.ambr index fb25e5fe4c48..7b60c8b3f06d 100644 --- a/tests/components/fumis/snapshots/test_climate.ambr +++ b/tests/components/fumis/snapshots/test_climate.ambr @@ -71,8 +71,8 @@ # name: test_climate_entity.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -97,7 +97,6 @@ 'model_id': None, 'name': 'Clou Duo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.6.0', 'via_device_id': None, diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index 20b246609c70..02414fc0750d 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup[Aqua Contour] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'My contour', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.0', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_setup[Timer] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'My timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.2.3', 'via_device_id': None, diff --git a/tests/components/gentex_homelink/snapshots/test_init.ambr b/tests/components/gentex_homelink/snapshots/test_init.ambr index d9d52e290bcc..7644cfae437a 100644 --- a/tests/components/gentex_homelink/snapshots/test_init.ambr +++ b/tests/components/gentex_homelink/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'TestDevice', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_drive/snapshots/test_sensor.ambr b/tests/components/google_drive/snapshots/test_sensor.ambr index 06df145129b9..870b016d6322 100644 --- a/tests/components/google_drive/snapshots/test_sensor.ambr +++ b/tests/components/google_drive/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensor.10 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://drive.google.com/drive/folders/HA folder ID', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'testuser@domain.com', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 63355f58df32..ff9dc38be179 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'Google AI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': None, 'name': 'Google AI STT', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': None, 'name': 'Google AI TTS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -112,7 +109,6 @@ 'model_id': None, 'name': 'Google AI Task', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/growatt_server/snapshots/test_init.ambr b/tests/components/growatt_server/snapshots/test_init.ambr index b1e65ceefadf..4aa7050670b8 100644 --- a/tests/components/growatt_server/snapshots/test_init.ambr +++ b/tests/components/growatt_server/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_classic_api_setup DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'TLX123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TLX123456', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'MIN123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'MIN123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/homee/snapshots/test_init.ambr b/tests/components/homee/snapshots/test_init.ambr index 4e073efb62fc..9d4e9ce436b1 100644 --- a/tests/components/homee/snapshots/test_init.ambr +++ b/tests/components/homee/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_general_data DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'TestHomee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.2.3', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_general_data.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -59,7 +58,6 @@ 'model_id': None, 'name': 'Shutter with position and slats', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '4.54', 'via_device_id': , diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 7d0cc7666fb5..1cfba8296a31 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -4,8 +4,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -26,7 +26,6 @@ 'model_id': None, 'name': 'Airversa AP2 1808', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '0.8.16', 'via_device_id': None, @@ -653,8 +652,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +674,6 @@ 'model_id': None, 'name': 'eufy HomeBase2-0AAA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000A', 'sw_version': '2.1.6', 'via_device_id': None, @@ -731,8 +729,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -753,7 +751,6 @@ 'model_id': None, 'name': 'eufyCam2-0000', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000D', 'sw_version': '1.6.7', 'via_device_id': , @@ -993,8 +990,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1015,7 +1012,6 @@ 'model_id': None, 'name': 'eufyCam2-000A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000B', 'sw_version': '1.6.7', 'via_device_id': , @@ -1255,8 +1251,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1277,7 +1273,6 @@ 'model_id': None, 'name': 'eufyCam2-000A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000C', 'sw_version': '1.6.7', 'via_device_id': , @@ -1521,8 +1516,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1543,7 +1538,6 @@ 'model_id': None, 'name': 'Aqara-Hub-E1-00A0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00aa00000a0', 'sw_version': '3.3.0', 'via_device_id': None, @@ -1744,8 +1738,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1766,7 +1760,6 @@ 'model_id': None, 'name': 'Contact Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '158d0007c59c6a', 'sw_version': '0', 'via_device_id': , @@ -1921,8 +1914,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1943,7 +1936,6 @@ 'model_id': None, 'name': 'Aqara Hub-1563', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0000000123456789', 'sw_version': '1.4.7', 'via_device_id': None, @@ -2212,8 +2204,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2234,7 +2226,6 @@ 'model_id': None, 'name': 'Programmable Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111a1111a1a111', 'sw_version': '9', 'via_device_id': None, @@ -2344,8 +2335,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2366,7 +2357,6 @@ 'model_id': None, 'name': 'ArloBabyA0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00A0000000000', 'sw_version': '1.10.931', 'via_device_id': None, @@ -2867,8 +2857,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2889,7 +2879,6 @@ 'model_id': None, 'name': 'InWall Outlet-0394DE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1020301376', 'sw_version': '1.0.0', 'via_device_id': None, @@ -3351,8 +3340,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3373,7 +3362,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB3C', 'sw_version': '1.0.0', 'via_device_id': , @@ -3526,8 +3514,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3548,7 +3536,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -4020,8 +4007,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4042,7 +4029,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB1C', 'sw_version': '1.0.0', 'via_device_id': , @@ -4195,8 +4181,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4217,7 +4203,6 @@ 'model_id': None, 'name': 'Porch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB2C', 'sw_version': '1.0.0', 'via_device_id': , @@ -4374,8 +4359,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4396,7 +4381,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -4644,8 +4628,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4666,7 +4650,6 @@ 'model_id': None, 'name': 'Basement Window 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -4907,8 +4890,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4929,7 +4912,6 @@ 'model_id': None, 'name': 'Deck Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5170,8 +5152,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5192,7 +5174,6 @@ 'model_id': None, 'name': 'Front Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5433,8 +5414,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5455,7 +5436,6 @@ 'model_id': None, 'name': 'Garage Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5696,8 +5676,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5718,7 +5698,6 @@ 'model_id': None, 'name': 'Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5966,8 +5945,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5988,7 +5967,6 @@ 'model_id': None, 'name': 'Living Room Window 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6229,8 +6207,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6251,7 +6229,6 @@ 'model_id': None, 'name': 'Loft window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6492,8 +6469,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6514,7 +6491,6 @@ 'model_id': None, 'name': 'Master BR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6762,8 +6738,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6784,7 +6760,6 @@ 'model_id': None, 'name': 'Master BR Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7025,8 +7000,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7047,7 +7022,6 @@ 'model_id': None, 'name': 'Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '4.8.70226', 'via_device_id': None, @@ -7433,8 +7407,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7455,7 +7429,6 @@ 'model_id': None, 'name': 'Upstairs BR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7703,8 +7676,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7725,7 +7698,6 @@ 'model_id': None, 'name': 'Upstairs BR Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7970,8 +7942,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7992,7 +7964,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -8468,8 +8439,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8490,7 +8461,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB3C', 'sw_version': '1.0.0', 'via_device_id': , @@ -8591,8 +8561,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8613,7 +8583,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -8896,8 +8865,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8918,7 +8887,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB1C', 'sw_version': '1.0.0', 'via_device_id': , @@ -9071,8 +9039,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9093,7 +9061,6 @@ 'model_id': None, 'name': 'Porch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB2C', 'sw_version': '1.0.0', 'via_device_id': , @@ -9250,8 +9217,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9272,7 +9239,6 @@ 'model_id': None, 'name': 'My ecobee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789016', 'sw_version': '4.7.340214', 'via_device_id': None, @@ -9757,8 +9723,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9779,7 +9745,6 @@ 'model_id': None, 'name': 'Master Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '4.5.130201', 'via_device_id': None, @@ -10074,8 +10039,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10096,7 +10061,6 @@ 'model_id': None, 'name': 'Eve Degree AA11', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AA00A0A00000', 'sw_version': '1.2.8', 'via_device_id': None, @@ -10465,8 +10429,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10487,7 +10451,6 @@ 'model_id': None, 'name': 'Eve Energy 50FF', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AA00A0A00000', 'sw_version': '1.2.9', 'via_device_id': None, @@ -10844,8 +10807,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10866,7 +10829,6 @@ 'model_id': None, 'name': 'HAA-C718B3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'C718B3-1', 'sw_version': '5.0.18', 'via_device_id': None, @@ -11068,8 +11030,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11090,7 +11052,6 @@ 'model_id': None, 'name': 'HAA-C718B3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'C718B3-2', 'sw_version': '5.0.18', 'via_device_id': None, @@ -11194,8 +11155,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11216,7 +11177,6 @@ 'model_id': None, 'name': 'Family Room North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.family_door_north', 'sw_version': '3.6.2', 'via_device_id': , @@ -11369,8 +11329,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11391,7 +11351,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -11447,8 +11406,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11469,7 +11428,6 @@ 'model_id': None, 'name': 'Kitchen Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.kitchen_window', 'sw_version': '3.6.2', 'via_device_id': , @@ -11626,8 +11584,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11648,7 +11606,6 @@ 'model_id': None, 'name': 'Ceiling Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.ceiling_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -11757,8 +11714,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11779,7 +11736,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -11835,8 +11791,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11857,7 +11813,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -11971,8 +11926,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11993,7 +11948,6 @@ 'model_id': None, 'name': '89 Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'climate.89_living_room', 'sw_version': '2024.2.0', 'via_device_id': , @@ -12325,8 +12279,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12347,7 +12301,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12407,8 +12360,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12429,7 +12382,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12485,8 +12437,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12507,7 +12459,6 @@ 'model_id': None, 'name': 'Laundry Smoke ED78', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'light.laundry_smoke_ed78', 'sw_version': '1.4.84', 'via_device_id': , @@ -12671,8 +12622,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12693,7 +12644,6 @@ 'model_id': None, 'name': 'Family Room North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.family_door_north', 'sw_version': '3.6.2', 'via_device_id': , @@ -12846,8 +12796,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12868,7 +12818,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12924,8 +12873,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12946,7 +12895,6 @@ 'model_id': None, 'name': 'Kitchen Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.kitchen_window', 'sw_version': '3.6.2', 'via_device_id': , @@ -13103,8 +13051,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13125,7 +13073,6 @@ 'model_id': None, 'name': 'Ceiling Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.ceiling_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13234,8 +13181,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13256,7 +13203,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -13312,8 +13258,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13334,7 +13280,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13449,8 +13394,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13471,7 +13416,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -13527,8 +13471,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13549,7 +13493,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13664,8 +13607,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13686,7 +13629,6 @@ 'model_id': None, 'name': '89 Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'climate.89_living_room', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14027,8 +13969,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14049,7 +13991,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14109,8 +14050,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14131,7 +14072,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14187,8 +14127,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14209,7 +14149,6 @@ 'model_id': None, 'name': 'Humidifier 182A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'humidifier.humidifier_182a', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14380,8 +14319,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14402,7 +14341,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14458,8 +14396,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14480,7 +14418,6 @@ 'model_id': None, 'name': 'Humidifier 182A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'humidifier.humidifier_182a', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14651,8 +14588,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14673,7 +14610,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14729,8 +14665,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14751,7 +14687,6 @@ 'model_id': None, 'name': 'Laundry Smoke ED78', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'light.laundry_smoke_ed78', 'sw_version': '1.4.84', 'via_device_id': , @@ -14925,8 +14860,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14947,7 +14882,6 @@ 'model_id': None, 'name': 'Air Conditioner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00000001', 'sw_version': '1.0.0', 'via_device_id': None, @@ -15139,8 +15073,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15161,7 +15095,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462395276914', 'sw_version': '1.46.13', 'via_device_id': , @@ -15279,8 +15212,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15301,7 +15234,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462395276939', 'sw_version': '1.46.13', 'via_device_id': , @@ -15419,8 +15351,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15441,7 +15373,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462403113447', 'sw_version': '1.46.13', 'via_device_id': , @@ -15559,8 +15490,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15581,7 +15512,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462403233419', 'sw_version': '1.46.13', 'via_device_id': , @@ -15699,8 +15629,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15721,7 +15651,6 @@ 'model_id': None, 'name': 'Hue ambiance spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462412411853', 'sw_version': '1.46.13', 'via_device_id': , @@ -15849,8 +15778,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15871,7 +15800,6 @@ 'model_id': None, 'name': 'Hue ambiance spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462412413293', 'sw_version': '1.46.13', 'via_device_id': , @@ -15999,8 +15927,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16021,7 +15949,6 @@ 'model_id': None, 'name': 'Hue dimmer switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462389072572', 'sw_version': '45.1.17846', 'via_device_id': , @@ -16339,8 +16266,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16361,7 +16288,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462378982941', 'sw_version': '1.46.13', 'via_device_id': , @@ -16471,8 +16397,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16493,7 +16419,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462378983942', 'sw_version': '1.46.13', 'via_device_id': , @@ -16603,8 +16528,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16625,7 +16550,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462379122122', 'sw_version': '1.46.13', 'via_device_id': , @@ -16735,8 +16659,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16757,7 +16681,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462379123707', 'sw_version': '1.46.13', 'via_device_id': , @@ -16867,8 +16790,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16889,7 +16812,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462383114163', 'sw_version': '1.46.13', 'via_device_id': , @@ -16999,8 +16921,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17021,7 +16943,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462383114193', 'sw_version': '1.46.13', 'via_device_id': , @@ -17131,8 +17052,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17153,7 +17074,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462385996792', 'sw_version': '1.46.13', 'via_device_id': , @@ -17263,8 +17183,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17285,7 +17205,6 @@ 'model_id': None, 'name': 'Philips hue - 482544', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': '1.32.1932126170', 'via_device_id': None, @@ -17345,8 +17264,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17367,7 +17286,6 @@ 'model_id': None, 'name': 'Koogeek-LS1-20833F', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAA011111111111', 'sw_version': '2.2.15', 'via_device_id': None, @@ -17491,8 +17409,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17513,7 +17431,6 @@ 'model_id': None, 'name': 'Koogeek-P1-A00AA0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'EUCP03190xxxxx48', 'sw_version': '2.3.7', 'via_device_id': None, @@ -17670,8 +17587,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17692,7 +17609,6 @@ 'model_id': None, 'name': 'Koogeek-SW2-187A91', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CNNT061751001372', 'sw_version': '1.0.3', 'via_device_id': None, @@ -17892,8 +17808,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17914,7 +17830,6 @@ 'model_id': None, 'name': 'Lennox', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXXXXXX', 'sw_version': '3.40.XX', 'via_device_id': None, @@ -18196,8 +18111,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18218,7 +18133,6 @@ 'model_id': None, 'name': 'LG webOS TV AF80', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '999AAAAAA999', 'sw_version': '04.71.04', 'via_device_id': None, @@ -18388,8 +18302,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18410,7 +18324,6 @@ 'model_id': None, 'name': 'Caséta® Wireless Fan Speed Control', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '39024290', 'sw_version': '001.005', 'via_device_id': , @@ -18519,8 +18432,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18541,7 +18454,6 @@ 'model_id': None, 'name': 'Smart Bridge 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12344331', 'sw_version': '08.08', 'via_device_id': None, @@ -18601,8 +18513,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18623,7 +18535,6 @@ 'model_id': None, 'name': 'MSS425F-15cc', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'HH41234', 'sw_version': '4.2.3', 'via_device_id': None, @@ -18903,8 +18814,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18925,7 +18836,6 @@ 'model_id': None, 'name': 'MSS565-28da', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'BB1121', 'sw_version': '4.1.9', 'via_device_id': None, @@ -19039,8 +18949,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19061,7 +18971,6 @@ 'model_id': None, 'name': 'Mysa-85dda9', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAA000', 'sw_version': '2.8.1', 'via_device_id': None, @@ -19395,8 +19304,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19417,7 +19326,6 @@ 'model_id': None, 'name': 'Nanoleaf Strip 3B32', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAA011111111111', 'sw_version': '1.4.40', 'via_device_id': None, @@ -19677,8 +19585,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19699,7 +19607,6 @@ 'model_id': None, 'name': 'Netatmo-Doorbell-g738658', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'g738658', 'sw_version': '80.0.0', 'via_device_id': None, @@ -19994,8 +19901,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20016,7 +19923,6 @@ 'model_id': None, 'name': 'Smart CO Alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '1.0.3', 'via_device_id': None, @@ -20166,8 +20072,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20188,7 +20094,6 @@ 'model_id': None, 'name': 'Healthy Home Coach', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAAAAAAAA', 'sw_version': '59', 'via_device_id': None, @@ -20498,8 +20403,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20520,7 +20425,6 @@ 'model_id': None, 'name': 'RainMachine-00ce4a', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00aa0000aa0a', 'sw_version': '1.0.4', 'via_device_id': None, @@ -20956,8 +20860,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20978,7 +20882,6 @@ 'model_id': None, 'name': 'Master Bath South', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21131,8 +21034,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21153,7 +21056,6 @@ 'model_id': None, 'name': 'RYSE SmartBridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0101.3521.0436', 'sw_version': '1.3.0', 'via_device_id': None, @@ -21209,8 +21111,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21231,7 +21133,6 @@ 'model_id': None, 'name': 'RYSE SmartShade', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '', 'sw_version': '', 'via_device_id': , @@ -21388,8 +21289,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21410,7 +21311,6 @@ 'model_id': None, 'name': 'BR Left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21563,8 +21463,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21585,7 +21485,6 @@ 'model_id': None, 'name': 'LR Left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21738,8 +21637,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21760,7 +21659,6 @@ 'model_id': None, 'name': 'LR Right', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21913,8 +21811,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21935,7 +21833,6 @@ 'model_id': None, 'name': 'RYSE SmartBridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0401.3521.0679', 'sw_version': '1.3.0', 'via_device_id': None, @@ -21991,8 +21888,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22013,7 +21910,6 @@ 'model_id': None, 'name': 'RZSS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -22170,8 +22066,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22192,7 +22088,6 @@ 'model_id': None, 'name': 'SENSE ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAA000', 'sw_version': '004.027.000', 'via_device_id': None, @@ -22297,8 +22192,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22319,7 +22214,6 @@ 'model_id': None, 'name': 'SIMPLEconnect Fan-06F674', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890abcd', 'sw_version': '', 'via_device_id': None, @@ -22487,8 +22381,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22509,7 +22403,6 @@ 'model_id': None, 'name': 'VELUX Internal Cover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '0.0.0', 'via_device_id': None, @@ -22617,8 +22510,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22639,7 +22532,6 @@ 'model_id': None, 'name': 'U by Moen-015F44', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '3.3.0', 'via_device_id': None, @@ -23042,8 +22934,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23064,7 +22956,6 @@ 'model_id': None, 'name': 'VELUX Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '16.0.0', 'via_device_id': None, @@ -23274,8 +23165,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23296,7 +23187,6 @@ 'model_id': None, 'name': 'VELUX Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1a11a1', 'sw_version': '70', 'via_device_id': None, @@ -23352,8 +23242,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23374,7 +23264,6 @@ 'model_id': None, 'name': 'VELUX Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a11b111', 'sw_version': '16', 'via_device_id': , @@ -23580,8 +23469,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23602,7 +23491,6 @@ 'model_id': None, 'name': 'VELUX Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1111111a114a111a', 'sw_version': '48', 'via_device_id': , @@ -23710,8 +23598,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23732,7 +23620,6 @@ 'model_id': None, 'name': 'VELUX Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '0.0.0', 'via_device_id': None, @@ -23840,8 +23727,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23862,7 +23749,6 @@ 'model_id': None, 'name': 'VELUX External Cover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '15.0.0', 'via_device_id': None, @@ -23969,8 +23855,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23991,7 +23877,6 @@ 'model_id': None, 'name': 'VOCOlinc-Flowerbud-0d324b', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AM01121849000327', 'sw_version': '3.121.2', 'via_device_id': None, @@ -24289,8 +24174,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24311,7 +24196,6 @@ 'model_id': None, 'name': 'VOCOlinc-VP3-123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'EU0121203xxxxx07', 'sw_version': '1.101.2', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index d1c4c93824cf..6d717631cbe2 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_identify_button.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 856ca1f32b34..e06105aa381d 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -62,8 +62,8 @@ # name: test_number_entities[HWE-SKT-11].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -88,7 +88,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -157,8 +156,8 @@ # name: test_number_entities[HWE-SKT-21].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -183,7 +182,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index 2fd3327220ff..7c1138e33c03 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -63,8 +63,8 @@ # name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -89,7 +89,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index cdd4f676608e..5c45661005f3 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_battery_cycles:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -90,8 +89,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -116,7 +115,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -183,8 +181,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -209,7 +207,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -276,8 +273,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -302,7 +299,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -369,8 +365,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -395,7 +391,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -462,8 +457,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -488,7 +483,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -555,8 +549,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -581,7 +575,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -648,8 +641,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_state_of_charge:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -674,7 +667,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -741,8 +733,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_uptime:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -767,7 +759,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -827,8 +818,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -853,7 +844,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -920,8 +910,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_wi_fi_rssi:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -946,7 +936,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -1009,8 +998,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1035,7 +1024,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -1094,8 +1082,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1120,7 +1108,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1187,8 +1174,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1213,7 +1200,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1280,8 +1266,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1306,7 +1292,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1373,8 +1358,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1399,7 +1384,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1466,8 +1450,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1492,7 +1476,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1559,8 +1542,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1585,7 +1568,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1652,8 +1634,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1678,7 +1660,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1742,8 +1723,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1768,7 +1749,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1835,8 +1815,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1861,7 +1841,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1928,8 +1907,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1954,7 +1933,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2021,8 +1999,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2047,7 +2025,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2106,8 +2083,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2132,7 +2109,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2195,8 +2171,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2221,7 +2197,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2288,8 +2263,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2314,7 +2289,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2381,8 +2355,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2407,7 +2381,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2474,8 +2447,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2500,7 +2473,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2567,8 +2539,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2593,7 +2565,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2660,8 +2631,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2686,7 +2657,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2753,8 +2723,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2779,7 +2749,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2846,8 +2815,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2872,7 +2841,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2939,8 +2907,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2965,7 +2933,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3032,8 +2999,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3058,7 +3025,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3125,8 +3091,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3151,7 +3117,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3218,8 +3183,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3244,7 +3209,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3311,8 +3275,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3337,7 +3301,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3401,8 +3364,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3427,7 +3390,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3491,8 +3453,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3517,7 +3479,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3581,8 +3542,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3607,7 +3568,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3674,8 +3634,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3700,7 +3660,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3767,8 +3726,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3793,7 +3752,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3860,8 +3818,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3886,7 +3844,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3953,8 +3910,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3979,7 +3936,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4046,8 +4002,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4072,7 +4028,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4139,8 +4094,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4165,7 +4120,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4232,8 +4186,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4258,7 +4212,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4325,8 +4278,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4351,7 +4304,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4418,8 +4370,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4444,7 +4396,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4511,8 +4462,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4537,7 +4488,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4604,8 +4554,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4630,7 +4580,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4689,8 +4638,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4715,7 +4664,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4778,8 +4726,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4804,7 +4752,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -4868,8 +4815,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_battery_group_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4894,7 +4841,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -4961,8 +4907,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_battery_group_target_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4987,7 +4933,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5054,8 +4999,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5080,7 +5025,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5147,8 +5091,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5173,7 +5117,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5240,8 +5183,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5266,7 +5209,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5333,8 +5275,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5359,7 +5301,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5418,8 +5359,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5444,7 +5385,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5511,8 +5451,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5537,7 +5477,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5604,8 +5543,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5630,7 +5569,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5697,8 +5635,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5723,7 +5661,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5790,8 +5727,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5816,7 +5753,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5883,8 +5819,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5909,7 +5845,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5976,8 +5911,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6002,7 +5937,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6069,8 +6003,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6095,7 +6029,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6162,8 +6095,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6188,7 +6121,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6255,8 +6187,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6281,7 +6213,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6348,8 +6279,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6374,7 +6305,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6441,8 +6371,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6467,7 +6397,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6526,8 +6455,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6552,7 +6481,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6616,8 +6544,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6642,7 +6570,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6709,8 +6636,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6735,7 +6662,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6794,8 +6720,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6820,7 +6746,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6887,8 +6812,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6913,7 +6838,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6980,8 +6904,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7006,7 +6930,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7073,8 +6996,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7099,7 +7022,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7158,8 +7080,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7184,7 +7106,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7243,8 +7164,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7269,7 +7190,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7342,8 +7262,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7368,7 +7288,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7435,8 +7354,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7461,7 +7380,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7528,8 +7446,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7554,7 +7472,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7621,8 +7538,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7647,7 +7564,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7714,8 +7630,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7740,7 +7656,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7799,8 +7714,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7825,7 +7740,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7884,8 +7798,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7910,7 +7824,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7969,8 +7882,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7995,7 +7908,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8054,8 +7966,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8080,7 +7992,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8139,8 +8050,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8165,7 +8076,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8224,8 +8134,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8250,7 +8160,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8317,8 +8226,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8343,7 +8252,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8402,8 +8310,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8428,7 +8336,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8491,8 +8398,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8513,7 +8420,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_G001', 'sw_version': None, 'via_device_id': , @@ -8580,8 +8486,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8602,7 +8508,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_H001', 'sw_version': None, 'via_device_id': , @@ -8669,8 +8574,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8691,7 +8596,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', 'sw_version': None, 'via_device_id': , @@ -8754,8 +8658,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8776,7 +8680,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', 'sw_version': None, 'via_device_id': , @@ -8843,8 +8746,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8865,7 +8768,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_W001', 'sw_version': None, 'via_device_id': , @@ -8932,8 +8834,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8958,7 +8860,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9022,8 +8923,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9048,7 +8949,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9115,8 +9015,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9141,7 +9041,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9208,8 +9107,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9234,7 +9133,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9301,8 +9199,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9327,7 +9225,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9386,8 +9283,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9412,7 +9309,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9479,8 +9375,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9505,7 +9401,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9572,8 +9467,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9598,7 +9493,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9665,8 +9559,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9691,7 +9585,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9758,8 +9651,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9784,7 +9677,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9851,8 +9743,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9877,7 +9769,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9944,8 +9835,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9970,7 +9861,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10037,8 +9927,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10063,7 +9953,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10130,8 +10019,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10156,7 +10045,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10223,8 +10111,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10249,7 +10137,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10316,8 +10203,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10342,7 +10229,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10409,8 +10295,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10435,7 +10321,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10494,8 +10379,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10520,7 +10405,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10584,8 +10468,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10610,7 +10494,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10677,8 +10560,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10703,7 +10586,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10762,8 +10644,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10788,7 +10670,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10855,8 +10736,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10881,7 +10762,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10948,8 +10828,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10974,7 +10854,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11041,8 +10920,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11067,7 +10946,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11126,8 +11004,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11152,7 +11030,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11211,8 +11088,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11237,7 +11114,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11310,8 +11186,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11336,7 +11212,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11403,8 +11278,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11429,7 +11304,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11496,8 +11370,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11522,7 +11396,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11589,8 +11462,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11615,7 +11488,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11682,8 +11554,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11708,7 +11580,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11767,8 +11638,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11793,7 +11664,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11852,8 +11722,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11878,7 +11748,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11937,8 +11806,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11963,7 +11832,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12022,8 +11890,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12048,7 +11916,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12107,8 +11974,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12133,7 +12000,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12192,8 +12058,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12218,7 +12084,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12285,8 +12150,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12311,7 +12176,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12370,8 +12234,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12396,7 +12260,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12459,8 +12322,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12481,7 +12344,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12548,8 +12410,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12570,7 +12432,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12637,8 +12498,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12659,7 +12520,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12722,8 +12582,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12744,7 +12604,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12811,8 +12670,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12833,7 +12692,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12900,8 +12758,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12926,7 +12784,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12990,8 +12847,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_battery_group_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13016,7 +12873,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13083,8 +12939,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_battery_group_target_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13109,7 +12965,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13176,8 +13031,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13202,7 +13057,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13269,8 +13123,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13295,7 +13149,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13362,8 +13215,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13388,7 +13241,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13455,8 +13307,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13481,7 +13333,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13540,8 +13391,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13566,7 +13417,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13633,8 +13483,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13659,7 +13509,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13726,8 +13575,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13752,7 +13601,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13819,8 +13667,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13845,7 +13693,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13912,8 +13759,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13938,7 +13785,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14005,8 +13851,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14031,7 +13877,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14098,8 +13943,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14124,7 +13969,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14191,8 +14035,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14217,7 +14061,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14284,8 +14127,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14310,7 +14153,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14377,8 +14219,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14403,7 +14245,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14470,8 +14311,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14496,7 +14337,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14563,8 +14403,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14589,7 +14429,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14648,8 +14487,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14674,7 +14513,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14738,8 +14576,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14764,7 +14602,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14831,8 +14668,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14857,7 +14694,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14916,8 +14752,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14942,7 +14778,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15009,8 +14844,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15035,7 +14870,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15102,8 +14936,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15128,7 +14962,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15195,8 +15028,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15221,7 +15054,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15280,8 +15112,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15306,7 +15138,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15365,8 +15196,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15391,7 +15222,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15464,8 +15294,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15490,7 +15320,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15557,8 +15386,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15583,7 +15412,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15650,8 +15478,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15676,7 +15504,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15743,8 +15570,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15769,7 +15596,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15836,8 +15662,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15862,7 +15688,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15921,8 +15746,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15947,7 +15772,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16006,8 +15830,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16032,7 +15856,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16091,8 +15914,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16117,7 +15940,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16176,8 +15998,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16202,7 +16024,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16261,8 +16082,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16287,7 +16108,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16346,8 +16166,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16372,7 +16192,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16439,8 +16258,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16465,7 +16284,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16524,8 +16342,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16550,7 +16368,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16613,8 +16430,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16635,7 +16452,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_G001', 'sw_version': None, 'via_device_id': , @@ -16702,8 +16518,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16724,7 +16540,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_H001', 'sw_version': None, 'via_device_id': , @@ -16791,8 +16606,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16813,7 +16628,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', 'sw_version': None, 'via_device_id': , @@ -16876,8 +16690,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16898,7 +16712,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', 'sw_version': None, 'via_device_id': , @@ -16965,8 +16778,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16987,7 +16800,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_W001', 'sw_version': None, 'via_device_id': , @@ -17054,8 +16866,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17080,7 +16892,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17144,8 +16955,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17170,7 +16981,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17237,8 +17047,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17263,7 +17073,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17330,8 +17139,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17356,7 +17165,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17423,8 +17231,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17449,7 +17257,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17516,8 +17323,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17542,7 +17349,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17609,8 +17415,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17635,7 +17441,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17702,8 +17507,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17728,7 +17533,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17795,8 +17599,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17821,7 +17625,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17888,8 +17691,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17914,7 +17717,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17981,8 +17783,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18007,7 +17809,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18074,8 +17875,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18100,7 +17901,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18167,8 +17967,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18193,7 +17993,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18260,8 +18059,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18286,7 +18085,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18353,8 +18151,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18379,7 +18177,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18446,8 +18243,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18472,7 +18269,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18531,8 +18327,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18557,7 +18353,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18624,8 +18419,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18650,7 +18445,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18709,8 +18503,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18735,7 +18529,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18802,8 +18595,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18828,7 +18621,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18895,8 +18687,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18921,7 +18713,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18988,8 +18779,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19014,7 +18805,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19081,8 +18871,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19107,7 +18897,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19174,8 +18963,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19200,7 +18989,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19267,8 +19055,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19293,7 +19081,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19360,8 +19147,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19386,7 +19173,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19445,8 +19231,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19471,7 +19257,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19530,8 +19315,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19556,7 +19341,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19615,8 +19399,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19641,7 +19425,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19700,8 +19483,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19726,7 +19509,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19785,8 +19567,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19811,7 +19593,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19870,8 +19651,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19896,7 +19677,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19963,8 +19743,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19989,7 +19769,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -20048,8 +19827,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20074,7 +19853,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -20137,8 +19915,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20163,7 +19941,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20230,8 +20007,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20256,7 +20033,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20323,8 +20099,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20349,7 +20125,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20416,8 +20191,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20442,7 +20217,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20509,8 +20283,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20535,7 +20309,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20602,8 +20375,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20628,7 +20401,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20687,8 +20459,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20713,7 +20485,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20776,8 +20547,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20802,7 +20573,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -20869,8 +20639,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20895,7 +20665,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -20962,8 +20731,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20988,7 +20757,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21055,8 +20823,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21081,7 +20849,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21148,8 +20915,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21174,7 +20941,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21241,8 +21007,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21267,7 +21033,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21334,8 +21099,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21360,7 +21125,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21424,8 +21188,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21450,7 +21214,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21517,8 +21280,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21543,7 +21306,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21610,8 +21372,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21636,7 +21398,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21703,8 +21464,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21729,7 +21490,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21796,8 +21556,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21822,7 +21582,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21881,8 +21640,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21907,7 +21666,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21970,8 +21728,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21996,7 +21754,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22063,8 +21820,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22089,7 +21846,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22156,8 +21912,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22182,7 +21938,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22241,8 +21996,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22267,7 +22022,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22330,8 +22084,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22356,7 +22110,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22423,8 +22176,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22449,7 +22202,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22516,8 +22268,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22542,7 +22294,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22609,8 +22360,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22635,7 +22386,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22702,8 +22452,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22728,7 +22478,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22795,8 +22544,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22821,7 +22570,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22888,8 +22636,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22914,7 +22662,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22978,8 +22725,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23004,7 +22751,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23071,8 +22817,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23097,7 +22843,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23164,8 +22909,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23190,7 +22935,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23257,8 +23001,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23283,7 +23027,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23342,8 +23085,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23368,7 +23111,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23431,8 +23173,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23457,7 +23199,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23524,8 +23265,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23550,7 +23291,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23617,8 +23357,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23643,7 +23383,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23710,8 +23449,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23736,7 +23475,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23803,8 +23541,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23829,7 +23567,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23896,8 +23633,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23922,7 +23659,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23989,8 +23725,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24015,7 +23751,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24082,8 +23817,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24108,7 +23843,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24175,8 +23909,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24201,7 +23935,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24268,8 +24001,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24294,7 +24027,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24361,8 +24093,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24387,7 +24119,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24454,8 +24185,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24480,7 +24211,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24547,8 +24277,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24573,7 +24303,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24637,8 +24366,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24663,7 +24392,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24727,8 +24455,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24753,7 +24481,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24817,8 +24544,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24843,7 +24570,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24910,8 +24636,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24936,7 +24662,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25003,8 +24728,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25029,7 +24754,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25096,8 +24820,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25122,7 +24846,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25189,8 +24912,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25215,7 +24938,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25282,8 +25004,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25308,7 +25030,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25375,8 +25096,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25401,7 +25122,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25468,8 +25188,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25494,7 +25214,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25561,8 +25280,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25587,7 +25306,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25654,8 +25372,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25680,7 +25398,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25747,8 +25464,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25773,7 +25490,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25840,8 +25556,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25866,7 +25582,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25925,8 +25640,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25951,7 +25666,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index d5d2a7ed1ebe..eb2a6c0e5483 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -78,7 +78,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -137,8 +136,8 @@ # name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -163,7 +162,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -223,8 +221,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -249,7 +247,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -308,8 +305,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -334,7 +331,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -393,8 +389,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -419,7 +415,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -479,8 +474,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -505,7 +500,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -564,8 +558,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -590,7 +584,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -649,8 +642,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -675,7 +668,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -734,8 +726,8 @@ # name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -760,7 +752,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -819,8 +810,8 @@ # name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -845,7 +836,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -904,8 +894,8 @@ # name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -930,7 +920,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 7e1759bb9347..53577e4734f0 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': 'garden', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': '450XH', 'name': 'Test Mower 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index 2e7369e8a6d4..c63142b9e13e 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup DeviceRegistryEntrySnapshot({ 'area_id': 'garden', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': '305', 'name': 'Husqvarna AutoMower', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/huum/snapshots/test_init.ambr b/tests/components/huum/snapshots/test_init.ambr index eed66315bc3e..64b5ad5bcf09 100644 --- a/tests/components/huum/snapshots/test_init.ambr +++ b/tests/components/huum/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Huum sauna', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ialarm/snapshots/test_init.ambr b/tests/components/ialarm/snapshots/test_init.ambr index f778c3e63300..18a007de83bc 100644 --- a/tests/components/ialarm/snapshots/test_init.ambr +++ b/tests/components/ialarm/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'iAlarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/intelliclima/snapshots/test_fan.ambr b/tests/components/intelliclima/snapshots/test_fan.ambr index 5719c5fa1be4..040aeccbe847 100644 --- a/tests/components/intelliclima/snapshots/test_fan.ambr +++ b/tests/components/intelliclima/snapshots/test_fan.ambr @@ -2,8 +2,8 @@ # name: test_all_fan_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/intelliclima/snapshots/test_select.ambr b/tests/components/intelliclima/snapshots/test_select.ambr index dd3924cad183..8d5ea5bf47ad 100644 --- a/tests/components/intelliclima/snapshots/test_select.ambr +++ b/tests/components/intelliclima/snapshots/test_select.ambr @@ -2,8 +2,8 @@ # name: test_all_select_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/intelliclima/snapshots/test_sensor.ambr b/tests/components/intelliclima/snapshots/test_sensor.ambr index 15e65167bb08..b858a934dcd8 100644 --- a/tests/components/intelliclima/snapshots/test_sensor.ambr +++ b/tests/components/intelliclima/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_all_sensor_entities.6 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 752ee97cef86..870e7e022357 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -14,8 +14,8 @@ # name: test_devices_creaction_ok[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -36,7 +36,6 @@ 'model_id': None, 'name': '[TEST] Light switch 0 (TEST_SERIAL_0)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index 02076bf55970..7c6b1a4c1611 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Luxemburger Str. 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_registry.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Bahnhofsstr. 1A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index 5fb786029b4e..4ea9f6eeda74 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'mock model', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12345678', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/jvc_projector/snapshots/test_init.ambr b/tests/components/jvc_projector/snapshots/test_init.ambr index 0842503ea258..c907e06a8a72 100644 --- a/tests/components/jvc_projector/snapshots/test_init.ambr +++ b/tests/components/jvc_projector/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'JVC Projector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/kiosker/snapshots/test_init.ambr b/tests/components/kiosker/snapshots/test_init.ambr index 403237a6f51a..6421912a9068 100644 --- a/tests/components/kiosker/snapshots/test_init.ambr +++ b/tests/components/kiosker/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Kiosker A98BE1CE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC', 'sw_version': 'Kiosker 25.1.1', 'via_device_id': None, diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index e91b88c2a551..7c54d4ddc62d 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_state.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -74,7 +74,6 @@ 'model_id': None, 'name': 'Outlet 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -83,8 +82,8 @@ # name: test_state.3 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -105,7 +104,6 @@ 'model_id': None, 'name': 'Power strip with 2 sockets', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -164,8 +162,8 @@ # name: test_state.6 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -186,7 +184,6 @@ 'model_id': None, 'name': 'Outlet 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -195,8 +192,8 @@ # name: test_state.7 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -217,7 +214,6 @@ 'model_id': None, 'name': 'Power strip with 2 sockets', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_bluetooth.ambr b/tests/components/lamarzocco/snapshots/test_bluetooth.ambr index 7749a94d7d98..177ebb7daa47 100644 --- a/tests/components/lamarzocco/snapshots/test_bluetooth.ambr +++ b/tests/components/lamarzocco/snapshots/test_bluetooth.ambr @@ -16,8 +16,8 @@ # name: test_setup_through_bluetooth_only[GS3 AV-entities1][device_bluetooth_GS012345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -42,7 +42,6 @@ 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GS012345', 'sw_version': None, 'via_device_id': None, @@ -161,8 +160,8 @@ # name: test_setup_through_bluetooth_only[Linea Micra-entities0][device_bluetooth_MR012345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -187,7 +186,6 @@ 'model_id': 'LINEAMICRA', 'name': 'MR012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'MR012345', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index bdebd35d6dda..6f9ff0084e3c 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GS012345', 'sw_version': 'v1.17', 'via_device_id': None, diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index e1b5a48fe27b..df0111f63531 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '1p7k_500006', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '500006', 'sw_version': '1.44', 'via_device_id': None, diff --git a/tests/components/lichess/snapshots/test_init.ambr b/tests/components/lichess/snapshots/test_init.ambr index 91ba6b5d91d5..9b2efcf1196a 100644 --- a/tests/components/lichess/snapshots/test_init.ambr +++ b/tests/components/lichess/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'DrNykterstein', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/lojack/snapshots/test_init.ambr b/tests/components/lojack/snapshots/test_init.ambr index b23664dd0329..8be5adc20beb 100644 --- a/tests/components/lojack/snapshots/test_init.ambr +++ b/tests/components/lojack/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '2021 Honda Accord', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1HGBH41JXMN109186', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 662ffd51cb46..0159ced905e7 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mastodon @trwnh@mastodon.social', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index ce8035f289b0..5ce8158cedce 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mealie', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'v3.7.0', 'via_device_id': None, diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr index 654e631cdda7..a6ae48bbdba0 100644 --- a/tests/components/meater/snapshots/test_init.ambr +++ b/tests/components/meater/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Meater Probe 40a72384', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/melnor/snapshots/test_init.ambr b/tests/components/melnor/snapshots/test_init.ambr index 575043cb8cdc..def342f7dea0 100644 --- a/tests/components/melnor/snapshots/test_init.ambr +++ b/tests/components/melnor/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'test_melnor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr index b5b830f4e5cb..0071bfd10e81 100644 --- a/tests/components/miele/snapshots/test_init.ambr +++ b/tests/components/miele/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'FNS 28463 E ed/', 'name': 'Freezer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'Dummy_Appliance_1', 'sw_version': '31.17', 'via_device_id': None, diff --git a/tests/components/mystrom/snapshots/test_init.ambr b/tests/components/mystrom/snapshots/test_init.ambr index 76e207a295af..0538ba5cb686 100644 --- a/tests/components/mystrom/snapshots/test_init.ambr +++ b/tests/components/mystrom/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_bulb DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'myStrom Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.58.0', 'via_device_id': None, diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr index 66b4c9efe356..3ff976524746 100644 --- a/tests/components/myuplink/snapshots/test_init.ambr +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[alfred-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Gotham City', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10001', 'sw_version': '9682R7A', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info[batman-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Batcave', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10002', 'sw_version': '9682R7B', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_info[robin-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Duckburg', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10003', 'sw_version': '9682R7C', 'via_device_id': None, diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr index f37f79b384bb..7e2b4f2bb798 100644 --- a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'To work', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -54,7 +53,6 @@ 'model_id': None, 'name': 'To home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index fd12cb9fb69c..b37ef2729d93 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[netatmo-0009999992] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Entrance Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[netatmo-0009999993] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Bubendorff blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[netatmo-00:11:22:33:00:11:45:fe] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Unknown 00:11:22:33:00:11:45:fe', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_devices[netatmo-1002003001] DeviceRegistryEntrySnapshot({ 'area_id': 'corridor', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Corridor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_devices[netatmo-12:34:56:00:00:a1:4c:da] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Consumption meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_devices[netatmo-12:34:56:00:01:01:01:a1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Bathroom light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': None, 'name': 'Line 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': None, 'name': 'Line 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': None, 'name': 'Line 3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#3] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': None, 'name': 'Line 4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#4] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': None, 'name': 'Line 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#5] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': None, 'name': 'Total', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#6] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': None, 'name': 'Gas', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#7] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': None, 'name': 'Hot water', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#8] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': None, 'name': 'Cold water', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': None, 'name': 'Écocompteur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_devices[netatmo-12:34:56:00:86:99] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': None, 'name': 'Window Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_devices[netatmo-12:34:56:00:f1:62] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': None, 'name': 'Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_devices[netatmo-12:34:56:03:1b:e4] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': None, 'name': 'Villa Garden', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_devices[netatmo-12:34:56:10:b9:0e] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': None, 'name': 'Front', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_devices[netatmo-12:34:56:10:f1:66] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': None, 'name': 'Netatmo-Doorbell', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_devices[netatmo-12:34:56:25:cf:a8] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_devices[netatmo-12:34:56:26:65:14] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': None, 'name': 'Livingroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_devices[netatmo-12:34:56:26:68:92] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': None, 'name': 'Baby Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +722,8 @@ # name: test_devices[netatmo-12:34:56:26:69:0c] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -768,7 +744,6 @@ 'model_id': None, 'name': 'Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -777,8 +752,8 @@ # name: test_devices[netatmo-12:34:56:3e:c5:46] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -799,7 +774,6 @@ 'model_id': None, 'name': 'Parents Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -808,8 +782,8 @@ # name: test_devices[netatmo-12:34:56:80:00:12:ac:f2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -830,7 +804,6 @@ 'model_id': None, 'name': 'Prise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -839,8 +812,8 @@ # name: test_devices[netatmo-12:34:56:80:1c:42] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -861,7 +834,6 @@ 'model_id': None, 'name': 'Villa Outdoor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -870,8 +842,8 @@ # name: test_devices[netatmo-12:34:56:80:44:92] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -892,7 +864,6 @@ 'model_id': None, 'name': 'Villa Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -901,8 +872,8 @@ # name: test_devices[netatmo-12:34:56:80:7e:18] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -923,7 +894,6 @@ 'model_id': None, 'name': 'Villa Bathroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -932,8 +902,8 @@ # name: test_devices[netatmo-12:34:56:80:bb:26] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -954,7 +924,6 @@ 'model_id': None, 'name': 'Villa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -963,8 +932,8 @@ # name: test_devices[netatmo-12:34:56:80:c1:ea] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -985,7 +954,6 @@ 'model_id': None, 'name': 'Villa Rain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -994,8 +962,8 @@ # name: test_devices[netatmo-222452125] DeviceRegistryEntrySnapshot({ 'area_id': 'bureau', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1016,7 +984,6 @@ 'model_id': None, 'name': 'Bureau Modulate', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1025,8 +992,8 @@ # name: test_devices[netatmo-2746182631] DeviceRegistryEntrySnapshot({ 'area_id': 'livingroom', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1047,7 +1014,6 @@ 'model_id': None, 'name': 'Livingroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1056,8 +1022,8 @@ # name: test_devices[netatmo-2833524037] DeviceRegistryEntrySnapshot({ 'area_id': 'entrada', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1078,7 +1044,6 @@ 'model_id': None, 'name': 'Valve1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1087,8 +1052,8 @@ # name: test_devices[netatmo-2940411577] DeviceRegistryEntrySnapshot({ 'area_id': 'cocina', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1109,7 +1074,6 @@ 'model_id': None, 'name': 'Valve2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1118,8 +1082,8 @@ # name: test_devices[netatmo-91763b24c43d3e344f424e8b] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1140,7 +1104,6 @@ 'model_id': None, 'name': 'MYHOME', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1112,8 @@ # name: test_devices[netatmo-Home avg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1171,7 +1134,6 @@ 'model_id': None, 'name': 'Home avg', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1180,8 +1142,8 @@ # name: test_devices[netatmo-Home max] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1202,7 +1164,6 @@ 'model_id': None, 'name': 'Home max', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1211,8 +1172,8 @@ # name: test_devices[netatmo-Home min] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1233,7 +1194,6 @@ 'model_id': None, 'name': 'Home min', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index fd58e6e0002d..a201c9baded4 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.5.1', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Netgear LM1200', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'FFFFFFFFFFFFF', 'sw_version': 'EC25AFFDR07A09M4G', 'via_device_id': None, diff --git a/tests/components/nrgkick/snapshots/test_init.ambr b/tests/components/nrgkick/snapshots/test_init.ambr index ef360a6b468e..4723836a404f 100644 --- a/tests/components/nrgkick/snapshots/test_init.ambr +++ b/tests/components/nrgkick/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.100', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'NRGkick Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TEST123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index f920b064f0bc..fbb9697e84ed 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[device_connections] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Connections', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info[device_spelling_bee] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Spelling Bee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_info[device_wordle] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wordle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index dc49f5f40424..3614096e5390 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Ohme Home Pro', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'chargerid', 'sw_version': 'v2.65', 'via_device_id': None, diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index c3d8d92a9d20..6efcdfed0b6a 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[ondilo_ico-W1122333044455] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Pool 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'W1122333044455', 'sw_version': '1.7.1-stable', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[ondilo_ico-W2233304445566] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Pool 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'W2233304445566', 'sw_version': '1.7.1-stable', 'via_device_id': None, diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr index 2573c34e1fad..f0aad4adec7c 100644 --- a/tests/components/onedrive/snapshots/test_init.ambr +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://onedrive.live.com/?id=root&cid=mock_drive_id', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'My Drive', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index d7e0d711c252..4209470f76da 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_registry[01.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'DS2401', 'name': '01.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_registry[05.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_registry[10.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_registry[12.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_registry[1D.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': , @@ -157,8 +152,8 @@ # name: test_registry[1F.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_registry[20.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': 'DS2450', 'name': '20.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_registry[22.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_registry[26.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_registry[28.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_registry[28.222222222222-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222222', 'sw_version': '3.2', 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_registry[28.222222222223-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222223', 'sw_version': '3.2', 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_registry[29.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_registry[30.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_registry[3A.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_registry[3B.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_registry[42.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_registry[7E.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_registry[7E.222222222222-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222222', 'sw_version': '3.2', 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_registry[7E.333333333333-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': 'EDS0065', 'name': '7E.333333333333', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '333333333333', 'sw_version': '3.2', 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_registry[A6.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': 'DS2438', 'name': 'A6.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_registry[EF.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_registry[EF.111111111112-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111112', 'sw_version': '3.2', 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_registry[EF.111111111113-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111113', 'sw_version': '3.2', 'via_device_id': None, diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index f5006ac979f1..87d719557790 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[mock_conversation_subentry_data0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18,7 +18,6 @@ 'model_id': None, 'name': 'OpenAI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -27,8 +26,8 @@ # name: test_devices[mock_conversation_subentry_data1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -43,7 +42,6 @@ 'model_id': None, 'name': 'OpenAI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index f861ccaa9ed0..6c8778536a63 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://overseerr.test', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Overseerr', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index 3fca1d851ce0..f1bf893c7483 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Stove', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0.0', 'via_device_id': None, diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index 21edc32c6290..207bf037b748 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_peblar_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.127', 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': '6004-2300-8002', 'name': 'Peblar EV Charger', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '23-45-A4O-MOF', 'sw_version': '1.6.1+1+WL-1', 'via_device_id': None, diff --git a/tests/components/pooldose/snapshots/test_init.ambr b/tests/components/pooldose/snapshots/test_init.ambr index b4a76f55c83b..627c692efc2b 100644 --- a/tests/components/pooldose/snapshots/test_init.ambr +++ b/tests/components/pooldose/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.100/index.html', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'PDPR1H1HAW100', 'name': 'Pool Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TEST123456789', 'sw_version': '1.30 (SW v2.10, API v1)', 'via_device_id': None, diff --git a/tests/components/portainer/snapshots/test_init.ambr b/tests/components/portainer/snapshots/test_init.ambr index 47eceb891301..237c4b507149 100644 --- a/tests/components/portainer/snapshots/test_init.ambr +++ b/tests/components/portainer/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/dashboard', 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'my-environment', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/ff31facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': None, 'name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': None, 'name': 'focused_einstein', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -112,15 +109,14 @@ 'model_id': None, 'name': 'funny_chatelet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -141,15 +137,14 @@ 'model_id': None, 'name': 'practical_morse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -170,15 +165,14 @@ 'model_id': None, 'name': 'serene_banach', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/stacks/webstack', 'connections': set({ }), @@ -199,15 +193,14 @@ 'model_id': None, 'name': 'webstack', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/stacks/dashy', 'connections': set({ }), @@ -228,15 +221,14 @@ 'model_id': None, 'name': 'dashy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -257,15 +249,14 @@ 'model_id': None, 'name': 'stoic_turing', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/dashy_config', 'connections': set({ }), @@ -286,15 +277,14 @@ 'model_id': None, 'name': 'dashy_config', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/db_data', 'connections': set({ }), @@ -315,15 +305,14 @@ 'model_id': None, 'name': 'db_data', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/myvolume', 'connections': set({ }), @@ -344,7 +333,6 @@ 'model_id': None, 'name': 'myvolume', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/prana/snapshots/test_init.ambr b/tests/components/prana/snapshots/test_init.ambr index 8c4f89b6b5e9..33a2e69e5955 100644 --- a/tests/components/prana/snapshots/test_init.ambr +++ b/tests/components/prana/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info_registered DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'PRANA RECUPERATOR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ECC9FFE0E574', 'sw_version': '46', 'via_device_id': None, diff --git a/tests/components/ps4/snapshots/test_media_player.ambr b/tests/components/ps4/snapshots/test_media_player.ambr index c4a9da3f2d56..df26937ecc9c 100644 --- a/tests/components/ps4/snapshots/test_media_player.ambr +++ b/tests/components/ps4/snapshots/test_media_player.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Fake PS4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '9.87', 'via_device_id': None, diff --git a/tests/components/rabbitair/snapshots/test_init.ambr b/tests/components/rabbitair/snapshots/test_init.ambr index dfa9712d58c1..5f11c7f10471 100644 --- a/tests/components/rabbitair/snapshots/test_init.ambr +++ b/tests/components/rabbitair/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Rabbit Air', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.3.17', 'via_device_id': None, diff --git a/tests/components/rainbird/snapshots/test_init.ambr b/tests/components/rainbird/snapshots/test_init.ambr index 594652e0c857..0c8ec24770de 100644 --- a/tests/components/rainbird/snapshots/test_init.ambr +++ b/tests/components/rainbird/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Rain Bird Controller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '9.12', 'via_device_id': None, diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index 9cc89cfcc9ea..2f6c9868e23f 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -7,8 +7,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -29,7 +29,6 @@ 'model_id': 'Z105-2-EMU2-LEDD_JM', 'name': 'RAVEn Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.0 (7400)', 'via_device_id': None, diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr index 7b898e593c3d..1e1f48792dd1 100644 --- a/tests/components/renault/snapshots/test_init.ambr +++ b/tests/components/renault/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': 'XJB1SU', 'name': 'REG-CAPTUR-FUEL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -36,8 +35,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -58,7 +57,6 @@ 'model_id': 'XJB1SU', 'name': 'REG-CAPTUR_PHEV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -69,8 +67,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -91,7 +89,6 @@ 'model_id': 'XCB1VE', 'name': 'REG-MEG-0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -102,8 +99,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -124,7 +121,6 @@ 'model_id': 'X071VE', 'name': 'REG-TWINGO-III', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -135,8 +131,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +153,6 @@ 'model_id': 'X101VE', 'name': 'REG-ZOE-40', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -168,8 +163,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -190,7 +185,6 @@ 'model_id': 'X102VE', 'name': 'REG-ZOE-50', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/renson/snapshots/test_init.ambr b/tests/components/renson/snapshots/test_init.ambr index 291d90b9ef9e..18468daea908 100644 --- a/tests/components/renson/snapshots/test_init.ambr +++ b/tests/components/renson/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Ventilation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'Firmware version 4.9.1', 'via_device_id': None, diff --git a/tests/components/ring/snapshots/test_init.ambr b/tests/components/ring/snapshots/test_init.ambr index 8bdcd59d7c0e..50e4aaa793ff 100644 --- a/tests/components/ring/snapshots/test_init.ambr +++ b/tests/components/ring/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Front Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 25925ac38654..ca49cd42e3f6 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_service DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '8381BE 13', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index b02f80f1dfd4..8470ec5fe35f 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.20.75', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'MCA-C5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 4be166ecf25b..96f97f1af1ba 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -73,8 +71,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -99,7 +97,6 @@ 'model_id': 'UE43LS003', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr index 86f1ff155827..f6649aefc54d 100644 --- a/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr @@ -56,8 +56,8 @@ # name: test_alarm_control_panel[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -78,7 +78,6 @@ 'model_id': None, 'name': 'Home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_binary_sensor.ambr b/tests/components/satel_integra/snapshots/test_binary_sensor.ambr index 5944744b8621..0a8c85b1389a 100644 --- a/tests/components/satel_integra/snapshots/test_binary_sensor.ambr +++ b/tests/components/satel_integra/snapshots/test_binary_sensor.ambr @@ -104,8 +104,8 @@ # name: test_binary_sensors[device-output] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -126,7 +126,6 @@ 'model_id': None, 'name': 'Output', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -135,8 +134,8 @@ # name: test_binary_sensors[device-zone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Zone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_init.ambr b/tests/components/satel_integra/snapshots/test_init.ambr index 9853a728ed61..a88c6922cc6b 100644 --- a/tests/components/satel_integra/snapshots/test_init.ambr +++ b/tests/components/satel_integra/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_parent_device_exists[parent-device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '192.168.0.2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/satel_integra/snapshots/test_sensor.ambr b/tests/components/satel_integra/snapshots/test_sensor.ambr index c8d63dc6b9b4..10c6478b3c6a 100644 --- a/tests/components/satel_integra/snapshots/test_sensor.ambr +++ b/tests/components/satel_integra/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors[device-zone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Zone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_switch.ambr b/tests/components/satel_integra/snapshots/test_switch.ambr index ec4a15864407..5ef82fb54bdc 100644 --- a/tests/components/satel_integra/snapshots/test_switch.ambr +++ b/tests/components/satel_integra/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_switches[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Switchable Output', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/saunum/snapshots/test_init.ambr b/tests/components/saunum/snapshots/test_init.ambr index 473bfe6ce139..1dfc03ed1eb1 100644 --- a/tests/components/saunum/snapshots/test_init.ambr +++ b/tests/components/saunum/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Saunum Leil', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index 1b6cc3f1cdb5..964bcfd5f2e9 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Vault Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0', 'via_device_id': None, diff --git a/tests/components/scrape/snapshots/test_init.ambr b/tests/components/scrape/snapshots/test_init.ambr index 45a049d7835b..a7c010f3aa1c 100644 --- a/tests/components/scrape/snapshots/test_init.ambr +++ b/tests/components/scrape/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_migrate_from_version_1_to_2[device_registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Current version', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index e01ca3ee4bc2..e5544ac276a7 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': 'bedroom', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0987654329', 'sw_version': 'PUR00111', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': 'hallway', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -62,15 +61,14 @@ 'model_id': None, 'name': 'Hallway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'SKY30046', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ }), @@ -91,15 +89,14 @@ 'model_id': None, 'name': 'Hallway Motion Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'V17', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -124,7 +121,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0987654321', 'sw_version': 'PUR00111', 'via_device_id': None, diff --git a/tests/components/sfr_box/snapshots/test_init.ambr b/tests/components/sfr_box/snapshots/test_init.ambr index fc136e73dd1d..b4b7bcb4fffd 100644 --- a/tests/components/sfr_box/snapshots/test_init.ambr +++ b/tests/components/sfr_box/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index 8b9713cb3711..20d4229fa9f8 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.2', 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'slide bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890ab', 'sw_version': '2', 'via_device_id': None, diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index b827e2183c0f..154aebf1f139 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -5,8 +5,8 @@ # name: test_devices[abl_light_b_001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -27,7 +27,6 @@ 'model_id': None, 'name': 'Kitchen Light 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -36,8 +35,8 @@ # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': 'toilet', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -58,7 +57,6 @@ 'model_id': None, 'name': 'Aeotec Energy Monitor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -67,8 +65,8 @@ # name: test_devices[aeotec_ms6] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -89,7 +87,6 @@ 'model_id': None, 'name': "Parent's Bedroom Sensor", 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -98,8 +95,8 @@ # name: test_devices[aeotec_smart_home_hub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Smart Home Hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '000.059.00008', 'via_device_id': None, @@ -145,8 +141,8 @@ # name: test_devices[aq_sensor_3_ikea] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -171,7 +167,6 @@ 'model_id': None, 'name': 'aq-sensor-3-ikea', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -180,8 +175,8 @@ # name: test_devices[aqara_g350] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -202,7 +197,6 @@ 'model_id': None, 'name': 'G350', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a4367b4d2bbfde94', 'sw_version': '4.5.20', 'via_device_id': None, @@ -211,8 +205,8 @@ # name: test_devices[aux_ac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -233,7 +227,6 @@ 'model_id': None, 'name': 'AUX A/C on-off', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -242,8 +235,8 @@ # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -264,7 +257,6 @@ 'model_id': None, 'name': 'Aeon Energy Monitor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -273,8 +265,8 @@ # name: test_devices[bosch_radiator_thermostat_ii] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -295,7 +287,6 @@ 'model_id': None, 'name': 'Radiator Thermostat II [+M] Wohnzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'D44867FFFEB37584', 'sw_version': '2.00.09', 'via_device_id': None, @@ -304,8 +295,8 @@ # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -326,7 +317,6 @@ 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -335,8 +325,8 @@ # name: test_devices[c2c_shade] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -357,7 +347,6 @@ 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -366,8 +355,8 @@ # name: test_devices[centralite] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -392,7 +381,6 @@ 'model_id': None, 'name': 'Dimmer Debian', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -401,8 +389,8 @@ # name: test_devices[contact_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -427,7 +415,6 @@ 'model_id': None, 'name': '.Front Door Open/Closed Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +423,8 @@ # name: test_devices[copper_water_meter_v03] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -458,7 +445,6 @@ 'model_id': None, 'name': 'Indoor Water Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +453,8 @@ # name: test_devices[da_ac_air_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -489,7 +475,6 @@ 'model_id': None, 'name': 'Air purifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARTIK051_TVTL_18K_12200115', 'via_device_id': None, @@ -498,8 +483,8 @@ # name: test_devices[da_ac_air_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -520,7 +505,6 @@ 'model_id': None, 'name': 'Air filter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AVT-WW-TP1-22-TOUCHOTN_12240702', 'via_device_id': None, @@ -529,8 +513,8 @@ # name: test_devices[da_ac_airsensor_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -551,7 +535,6 @@ 'model_id': None, 'name': '에어모니터 플러스', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', 'via_device_id': None, @@ -560,8 +543,8 @@ # name: test_devices[da_ac_cac_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -582,7 +565,6 @@ 'model_id': None, 'name': 'Ar Varanda', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ASA-WW-TP1-24-PACCOM_14240625', 'via_device_id': None, @@ -591,8 +573,8 @@ # name: test_devices[da_ac_ehs_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -613,7 +595,6 @@ 'model_id': None, 'name': 'Heat pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AEH-WW-TP1-22-AE6000_17240903', 'via_device_id': None, @@ -622,8 +603,8 @@ # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -644,7 +625,6 @@ 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +633,8 @@ # name: test_devices[da_ac_rac_000003] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -675,7 +655,6 @@ 'model_id': None, 'name': 'Clim Salon', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARTIK051_PRAC_20K_11230313', 'via_device_id': None, @@ -684,8 +663,8 @@ # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -706,7 +685,6 @@ 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, @@ -715,8 +693,8 @@ # name: test_devices[da_ac_rac_100001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -737,7 +715,6 @@ 'model_id': None, 'name': 'Corridor A/C', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +723,8 @@ # name: test_devices[da_ks_cooktop_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -768,7 +745,6 @@ 'model_id': None, 'name': 'Table de cuisson', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP2X-20-COOKTOP_40230515', 'via_device_id': None, @@ -777,8 +753,8 @@ # name: test_devices[da_ks_cooktop_31001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -799,7 +775,6 @@ 'model_id': 'NZ64B5046GK', 'name': 'Induction Hob', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'B8C878DX900290H', 'sw_version': None, 'via_device_id': None, @@ -808,8 +783,8 @@ # name: test_devices[da_ks_hood_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -830,7 +805,6 @@ 'model_id': None, 'name': 'Range hood', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AHD-WW-TP1-22-COMMON_40230419', 'via_device_id': None, @@ -839,8 +813,8 @@ # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -861,7 +835,6 @@ 'model_id': None, 'name': 'Microwave', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, @@ -870,8 +843,8 @@ # name: test_devices[da_ks_oven_01061] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -892,7 +865,6 @@ 'model_id': None, 'name': 'Oven', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', 'via_device_id': None, @@ -901,8 +873,8 @@ # name: test_devices[da_ks_oven_0107x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -923,7 +895,6 @@ 'model_id': None, 'name': 'Kitchen oven', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1-22-OVEN-1_40250221', 'via_device_id': None, @@ -932,8 +903,8 @@ # name: test_devices[da_ks_range_0101x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -954,7 +925,6 @@ 'model_id': None, 'name': 'Vulcan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', 'via_device_id': None, @@ -963,8 +933,8 @@ # name: test_devices[da_ks_walloven_0107x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -985,7 +955,6 @@ 'model_id': None, 'name': 'Four', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20230413.181729', 'via_device_id': None, @@ -994,8 +963,8 @@ # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1016,7 +985,6 @@ 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, @@ -1025,8 +993,8 @@ # name: test_devices[da_ref_normal_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1047,7 +1015,6 @@ 'model_id': None, 'name': 'Refrigerator 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20240616.213423', 'via_device_id': None, @@ -1056,8 +1023,8 @@ # name: test_devices[da_ref_normal_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1078,7 +1045,6 @@ 'model_id': None, 'name': 'Frigo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', 'via_device_id': None, @@ -1087,8 +1053,8 @@ # name: test_devices[da_ref_normal_01011_onedoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1109,7 +1075,6 @@ 'model_id': 'RR39C7EC5B1/EF', 'name': 'Lodówka', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP1-24-T4-COM_20250706', 'via_device_id': None, @@ -1118,8 +1083,8 @@ # name: test_devices[da_ref_normal_100001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1140,7 +1105,6 @@ 'model_id': None, 'name': 'Kjøleskap', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1113,8 @@ # name: test_devices[da_rvc_map_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1171,7 +1135,6 @@ 'model_id': None, 'name': 'Robot Vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20260120.215157', 'via_device_id': None, @@ -1180,8 +1143,8 @@ # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1202,7 +1165,6 @@ 'model_id': None, 'name': 'Robot vacuum 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0', 'via_device_id': None, @@ -1211,8 +1173,8 @@ # name: test_devices[da_sac_ehs_000001_sub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1233,7 +1195,6 @@ 'model_id': None, 'name': 'Eco Heating System', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1242,8 +1203,8 @@ # name: test_devices[da_sac_ehs_000001_sub_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1264,7 +1225,6 @@ 'model_id': None, 'name': 'Heat Pump Main', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1273,8 +1233,8 @@ # name: test_devices[da_sac_ehs_000002_sub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1295,7 +1255,6 @@ 'model_id': None, 'name': 'Wärmepumpe', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1304,8 +1263,8 @@ # name: test_devices[da_vc_stick_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1326,7 +1285,6 @@ 'model_id': 'VS28C9784QK/WA', 'name': 'Stick vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-VSWW-TP1-23-VS9700_51250514', 'via_device_id': None, @@ -1335,8 +1293,8 @@ # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1357,7 +1315,6 @@ 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, @@ -1366,8 +1323,8 @@ # name: test_devices[da_wm_dw_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1388,7 +1345,6 @@ 'model_id': 'DW60BG850B00ET', 'name': 'Dishwasher 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DW_TP1_21_COMMON_30250513', 'via_device_id': None, @@ -1397,8 +1353,8 @@ # name: test_devices[da_wm_mf_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1419,7 +1375,6 @@ 'model_id': None, 'name': 'Filtro in microfibra', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AMF-WW-TP1-22-COMMON_30230323', 'via_device_id': None, @@ -1428,8 +1383,8 @@ # name: test_devices[da_wm_sc_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1450,7 +1405,6 @@ 'model_id': None, 'name': 'AirDresser', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', 'via_device_id': None, @@ -1459,8 +1413,8 @@ # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1481,7 +1435,6 @@ 'model_id': None, 'name': 'Dryer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1490,8 +1443,8 @@ # name: test_devices[da_wm_wd_000001_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1512,7 +1465,6 @@ 'model_id': None, 'name': 'Seca-Roupa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1521,8 +1473,8 @@ # name: test_devices[da_wm_wd_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1543,7 +1495,6 @@ 'model_id': 'DV90DB8845GHU2', 'name': 'Trockner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30250508', 'via_device_id': None, @@ -1552,8 +1503,8 @@ # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1574,7 +1525,6 @@ 'model_id': None, 'name': 'Washer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, @@ -1583,8 +1533,8 @@ # name: test_devices[da_wm_wm_000001_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1605,7 +1555,6 @@ 'model_id': None, 'name': 'Washing Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1614,8 +1563,8 @@ # name: test_devices[da_wm_wm_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1636,7 +1585,6 @@ 'model_id': None, 'name': 'Machine à Laver', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30240927', 'via_device_id': None, @@ -1645,8 +1593,8 @@ # name: test_devices[da_wm_wm_100001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1667,7 +1615,6 @@ 'model_id': None, 'name': 'Washer 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1676,8 +1623,8 @@ # name: test_devices[da_wm_wm_100002] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1698,7 +1645,6 @@ 'model_id': None, 'name': 'Washer 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1707,8 +1653,8 @@ # name: test_devices[ecobee_sensor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1729,7 +1675,6 @@ 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250206213001', 'via_device_id': None, @@ -1738,8 +1683,8 @@ # name: test_devices[ecobee_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1760,7 +1705,6 @@ 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250206151734', 'via_device_id': None, @@ -1769,8 +1713,8 @@ # name: test_devices[ecobee_thermostat_offline] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1791,7 +1735,6 @@ 'model_id': None, 'name': 'Downstairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250308073247', 'via_device_id': None, @@ -1800,8 +1743,8 @@ # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1822,7 +1765,6 @@ 'model_id': None, 'name': 'Fake fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1831,8 +1773,8 @@ # name: test_devices[fibaro_dimmer_2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1853,7 +1795,6 @@ 'model_id': None, 'name': 'Dimmer entré 1 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1862,8 +1803,8 @@ # name: test_devices[gas_detector] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -1888,7 +1829,6 @@ 'model_id': None, 'name': 'Gas Detector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1897,8 +1837,8 @@ # name: test_devices[gas_meter] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1919,7 +1859,6 @@ 'model_id': None, 'name': 'Gas Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1928,8 +1867,8 @@ # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1950,7 +1889,6 @@ 'model_id': None, 'name': 'Basement Exit Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1959,8 +1897,8 @@ # name: test_devices[generic_ef00_v1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -1985,7 +1923,6 @@ 'model_id': None, 'name': 'Thermostat Küche', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1994,8 +1931,8 @@ # name: test_devices[generic_fan_3_speed] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2016,7 +1953,6 @@ 'model_id': None, 'name': 'Bedroom Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2025,8 +1961,8 @@ # name: test_devices[heatit_zpushwall] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2047,7 +1983,6 @@ 'model_id': None, 'name': 'Livingroom smart switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2056,8 +1991,8 @@ # name: test_devices[heatit_ztrm3_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2078,7 +2013,6 @@ 'model_id': None, 'name': 'Hall thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2087,8 +2021,8 @@ # name: test_devices[hue_color_temperature_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2109,7 +2043,6 @@ 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.122.2', 'via_device_id': None, @@ -2118,8 +2051,8 @@ # name: test_devices[hue_rgbw_color_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2140,7 +2073,6 @@ 'model_id': None, 'name': 'Standing light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.122.2', 'via_device_id': None, @@ -2149,8 +2081,8 @@ # name: test_devices[hw_q80r_soundbar] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2171,7 +2103,6 @@ 'model_id': None, 'name': 'Soundbar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'HW-Q80RWWB-1012.6', 'via_device_id': None, @@ -2180,8 +2111,8 @@ # name: test_devices[ikea_kadrilj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2206,7 +2137,6 @@ 'model_id': None, 'name': 'Kitchen IKEA KADRILJ Window blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2215,8 +2145,8 @@ # name: test_devices[ikea_leak_battery] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2237,7 +2167,6 @@ 'model_id': None, 'name': 'Waschkeller Feuchtigkeitssensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.11', 'via_device_id': , @@ -2246,8 +2175,8 @@ # name: test_devices[ikea_motion_illuminance_battery] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2268,7 +2197,6 @@ 'model_id': None, 'name': 'Gaderobe Bewegungsmelder', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.7', 'via_device_id': , @@ -2277,8 +2205,8 @@ # name: test_devices[ikea_plug_powermeter] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2303,7 +2231,6 @@ 'model_id': None, 'name': 'IKEA Plug Powermeter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -2312,8 +2239,8 @@ # name: test_devices[im_smarttag2_ble_uwb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2334,7 +2261,6 @@ 'model_id': None, 'name': 'SmartTag+ black', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2343,8 +2269,8 @@ # name: test_devices[im_speaker_ai_0001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2365,7 +2291,6 @@ 'model_id': None, 'name': 'Galaxy Home Mini', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'V310XXU1AWK1', 'via_device_id': None, @@ -2374,8 +2299,8 @@ # name: test_devices[iphone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2396,7 +2321,6 @@ 'model_id': None, 'name': 'iPhone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2405,8 +2329,8 @@ # name: test_devices[lumi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2431,7 +2355,6 @@ 'model_id': None, 'name': 'Outdoor Temp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2440,8 +2363,8 @@ # name: test_devices[meross_plug] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2462,7 +2385,6 @@ 'model_id': None, 'name': 'Waschkeller Trockner Plug', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '510802250905784', 'sw_version': '9.3.26', 'via_device_id': , @@ -2471,8 +2393,8 @@ # name: test_devices[multipurpose_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2497,7 +2419,6 @@ 'model_id': None, 'name': 'Deck Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2506,8 +2427,8 @@ # name: test_devices[sensi_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2528,7 +2449,6 @@ 'model_id': None, 'name': 'Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '6004971003', 'via_device_id': None, @@ -2537,8 +2457,8 @@ # name: test_devices[sensibo_airconditioner_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2559,7 +2479,6 @@ 'model_id': None, 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SKY40147', 'via_device_id': None, @@ -2568,8 +2487,8 @@ # name: test_devices[siemens_washer] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2590,7 +2509,6 @@ 'model_id': None, 'name': 'Wasmachine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2599,8 +2517,8 @@ # name: test_devices[smart_plug] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2625,7 +2543,6 @@ 'model_id': None, 'name': 'Arlo Beta Basestation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2634,8 +2551,8 @@ # name: test_devices[sonos_player] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2656,7 +2573,6 @@ 'model_id': None, 'name': 'Elliots Rum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2665,8 +2581,8 @@ # name: test_devices[tesla_powerwall] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2687,7 +2603,6 @@ 'model_id': None, 'name': 'Powerwall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2696,8 +2611,8 @@ # name: test_devices[tplink_p110] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2718,7 +2633,6 @@ 'model_id': None, 'name': 'Spülmaschine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.3.1 Build 240621 Rel.162048', 'via_device_id': None, @@ -2727,8 +2641,8 @@ # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2749,7 +2663,6 @@ 'model_id': None, 'name': 'Soundbar Living', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, @@ -2758,8 +2671,8 @@ # name: test_devices[vd_network_audio_003s] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2780,7 +2693,6 @@ 'model_id': None, 'name': 'Soundbar 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SAT-MT8532D24WWC-1016.0', 'via_device_id': None, @@ -2789,8 +2701,8 @@ # name: test_devices[vd_sensor_light_2023] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2811,7 +2723,6 @@ 'model_id': None, 'name': 'Light Sensor - 55" The Frame', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'latest', 'via_device_id': None, @@ -2820,8 +2731,8 @@ # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2842,7 +2753,6 @@ 'model_id': None, 'name': '[TV] Samsung 8 Series (49)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, @@ -2851,8 +2761,8 @@ # name: test_devices[virtual_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2873,7 +2783,6 @@ 'model_id': None, 'name': 'virtual thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2882,8 +2791,8 @@ # name: test_devices[virtual_valve] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2904,7 +2813,6 @@ 'model_id': None, 'name': 'volvo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2913,8 +2821,8 @@ # name: test_devices[virtual_water_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2935,7 +2843,6 @@ 'model_id': None, 'name': 'virtual water sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2944,8 +2851,8 @@ # name: test_devices[yale_push_button_deadbolt_lock] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2970,7 +2877,6 @@ 'model_id': None, 'name': 'Basement Door Lock', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2979,8 +2885,8 @@ # name: test_hub_via_device DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -3009,7 +2915,6 @@ 'model_id': None, 'name': 'Home Hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '000.055.00005', 'via_device_id': None, diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index 109fd649533e..4bab38a90090 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '127', 'via_device_id': None, diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 7f46daef13cd..3a78736a682e 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'core: v2.3.6 / zigbee: 20240314', 'via_device_id': None, diff --git a/tests/components/snooz/snapshots/test_init.ambr b/tests/components/snooz/snapshots/test_init.ambr index 79c37a923a35..2428bdd78c12 100644 --- a/tests/components/snooz/snapshots/test_init.ambr +++ b/tests/components/snooz/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': None, 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr index 03678ef4ff83..c4e33fea3244 100644 --- a/tests/components/squeezebox/snapshots/test_init.ambr +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Test Player', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '', 'via_device_id': , @@ -37,8 +36,8 @@ # name: test_device_registry_server_merged DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -67,7 +66,6 @@ 'model_id': 'LMS', 'name': '1.1.1.1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '', 'via_device_id': , diff --git a/tests/components/steam_online/snapshots/test_init.ambr b/tests/components/steam_online/snapshots/test_init.ambr index 9cec5ffc35b0..17711814c7c8 100644 --- a/tests/components/steam_online/snapshots/test_init.ambr +++ b/tests/components/steam_online/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://steamcommunity.com/profiles/123456789/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'testaccount1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/sunricher_dali/snapshots/test_init.ambr b/tests/components/sunricher_dali/snapshots/test_init.ambr index ca94d3b5dffe..87052be3a781 100644 --- a/tests/components/sunricher_dali/snapshots/test_init.ambr +++ b/tests/components/sunricher_dali/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Test Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6A242121110E', 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -58,15 +57,14 @@ 'model_id': None, 'name': 'Dimmer 0000-02', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -87,15 +85,14 @@ 'model_id': None, 'name': 'CCT 0000-03', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -116,15 +113,14 @@ 'model_id': None, 'name': 'HS Color Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -145,7 +141,6 @@ 'model_id': None, 'name': 'RGBW Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 42dedc115dc7..684bc3a6f3ec 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -53,8 +53,8 @@ # name: test_number_entities[binary_sensor.door_1_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': None, 'name': 'Door 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , @@ -135,8 +134,8 @@ # name: test_number_entities[binary_sensor.door_2_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Door 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index c3e135498986..a1e7656951ed 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_number_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': None, 'name': 'Tailwind iQ3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': None, diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index e6670bc31ed3..d3aa188a6931 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -55,8 +55,8 @@ # name: test_cover_entities[cover.door_1].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -77,7 +77,6 @@ 'model_id': None, 'name': 'Door 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , @@ -139,8 +138,8 @@ # name: test_cover_entities[cover.door_2].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -161,7 +160,6 @@ 'model_id': None, 'name': 'Door 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index 46d08aac7c3b..737ceee9b1e4 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -62,8 +62,8 @@ # name: test_number_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -88,7 +88,6 @@ 'model_id': None, 'name': 'Tailwind iQ3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': None, diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index 38874d08f3af..e356fd427b19 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_bridge_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Bridge-AB1C', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0000-0000', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_lock_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'Tedee PRO', 'name': 'Lock-1A2B', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index 456df2b3c34c..69ff7de8b147 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -53,8 +53,8 @@ # name: test_lock_without_pullspring.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': 'Tedee GO', 'name': 'Lock-2C3D', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/teltonika/snapshots/test_init.ambr b/tests/components/teltonika/snapshots/test_init.ambr index 6280477ceeaa..1b0158f00d79 100644 --- a/tests/components/teltonika/snapshots/test_init.ambr +++ b/tests/components/teltonika/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_creation DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://192.168.1.1', 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'RUTX50 Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'RUTX_R_00.07.17.3', 'via_device_id': None, diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index 7ce999659005..7edbf70f56c0 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[{('tesla_fleet', '123456')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Energy Site', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[{('tesla_fleet', 'LRWXF7EK4KC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'LRWXF7EK4KC700000', 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[{('tesla_fleet', 'abd-123')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_devices[{('tesla_fleet', 'bcd-234')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '234', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index 722aacd989b8..a4b842e36a23 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[{('teslemetry', '123456')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console/energy/123456', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Energy Site', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': '23.44.0 eb113390', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[{('teslemetry', 'LRW3F7EK4NC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console/vehicle/LRW3F7EK4NC700000', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': '3', 'name': 'Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'LRW3F7EK4NC700000', 'sw_version': '2026.0.0', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[{('teslemetry', 'abd-123')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_devices[{('teslemetry', 'bcd-234')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '234', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index 9e2620313a0d..d86c2fccbbf3 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Wallet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '01.12.14.0', 'via_device_id': None, diff --git a/tests/components/togrill/snapshots/test_init.ambr b/tests/components/togrill/snapshots/test_init.ambr index e4208e702ccb..dab4387e538f 100644 --- a/tests/components/togrill/snapshots/test_init.ambr +++ b/tests/components/togrill/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup_device_present DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'Pro-05', 'name': 'Pro-05', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index e2e4f37c2621..17a67905ff30 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -419,8 +419,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -445,7 +445,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 4d0149b13483..b654ca41dc25 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -611,8 +611,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -637,7 +637,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index 0f9158c0e9d5..b6404ac7da79 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -55,8 +55,8 @@ # name: test_states[my_camera-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -81,7 +81,6 @@ 'model_id': None, 'name': 'my_camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index f40b2e512da3..bba51badaf66 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -69,8 +69,8 @@ # name: test_states[thermostat-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -91,7 +91,6 @@ 'model_id': None, 'name': 'thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': , diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index 993a8d671613..38818dc04a8f 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -173,8 +173,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -199,7 +199,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index f1fdeba67238..8b3dcca8e269 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index a5e70e452e57..6ae6dd424ae0 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 2c845448bb0b..b6163e9e0a52 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index b0dec2f49acc..06e036d4ddaa 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -2,8 +2,8 @@ # name: test_states[hub-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 3654e37c8b2b..e5137655d40b 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index 0d432cb0a014..a057c3523817 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -2,8 +2,8 @@ # name: test_states[my_vacuum-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/trmnl/snapshots/test_init.ambr b/tests/components/trmnl/snapshots/test_init.ambr index 64e84eda1a00..0da5c5be56b6 100644 --- a/tests/components/trmnl/snapshots/test_init.ambr +++ b/tests/components/trmnl/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Test TRMNL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 6c9cbee97bde..30be6ff71032 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry[0hlcxgoadnrh03yaqkydsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'ay30hrndaogxclh0', 'name': 'LCDÕ▒ŵ©®µ╣┐Õ║ªõ©çÞâ¢ÚüѵĺÕÖ¿', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_registry[0qtza8cv6q5rdxpxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'xpxdr5q6vc8aztq0', 'name': 'Weather station', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_registry[0wep74vtderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': '47pew0', 'name': 'TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_device_registry[18yvbamhgkjc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': 'hmabvy81', 'name': 'Interruptor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_device_registry[1nw1rysgyj8th1l5qbnxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': '5l1ht8jygsyr1wn1', 'name': 'Panneaux solaires 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_device_registry[2k8wyjo7iidkohuczc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': 'cuhokdii7ojyw8k2', 'name': 'Buitenverlichting', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_device_registry[2myxayqtud9aqbizsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': 'zibqa9dutqyaxym2', 'name': 'Dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_device_registry[2pxfek1jjrtctiyglam] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': 'gyitctrjj1kefxp2', 'name': 'Multifunction alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_device_registry[2w46jyhngklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': 'nhyj64w2', 'name': 'Tapparelle studio', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_device_registry[2x473nefusdo7af6zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': '6fa7odsufen374x2', 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_device_registry[3d4yosotwk27nqxvzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': 'vxqn72kwtosoy4d3', 'name': 'Garage Socket', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_device_registry[3kdnp0ajo7zdolfxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': 'xflodz7oja0pndk3', 'name': 'Sensor T & H Server Home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_device_registry[3phkffywh5nnlj5vbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': 'v5jlnn5hwyffkhp3', 'name': 'Production', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_device_registry[3uqk1csjqplf3uxqscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': 'qxu3flpqjsc1kqu3', 'name': 'Garage Contact Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_device_registry[49m7h9lh3t8pq6ftzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': 'tf6qp8t3hl9h7m94', 'name': 'Consommation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_device_registry[4bxfp3kgncpcgx5uycjzs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': 'u5xgcpcngk3pfxb4', 'name': 'YINMIK Water Quality Tester', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_device_registry[4fO1qIzYbcdMUHqAjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': 'AqHUMdcbYzIq1Of4', 'name': 'Landing', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_device_registry[4hbnivc4w2rsw966lc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': '669wsr2w4cvinbh4', 'name': 'VIVIDSTORM SCREEN', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_device_registry[4pa1uobdjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': 'dbou1ap4', 'name': 'Lumy Garage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_device_registry[4q5c2am8n1bwb6bszc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': 'sb6bwb1n8ma2c5q4', 'name': 'Socket4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_device_registry[51tdkcsamisw9ukycp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': 'yku9wsimasckdt15', 'name': 'Framboisier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_device_registry[53apxfah2qoxb1cgkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': 'gc1bxoq2hafxpa35', 'name': 'Полотенцосушитель', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_device_registry[53fnjncm3jywuaznps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': 'nzauwyj3mcnjnf35', 'name': 'Garage Camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_device_registry[5ebss29hqqmse7t5psm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': '5t7esmqqh92ssbe5', 'name': 'Slimme kattenbak', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +722,8 @@ # name: test_device_registry[5gfyvvg48bsxbbnjzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -768,7 +744,6 @@ 'model_id': 'jnbbxsb84gvvyfg5', 'name': 'Bathroom Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -777,8 +752,8 @@ # name: test_device_registry[63cninaczt9dwo7v2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -799,7 +774,6 @@ 'model_id': 'v7owd9tzcaninc36', 'name': 'Gateway2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -808,8 +782,8 @@ # name: test_device_registry[69dth3rxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -830,7 +804,6 @@ 'model_id': 'xr3htd96', 'name': 'Humy toilettes RDC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -839,8 +812,8 @@ # name: test_device_registry[6ffyxwrjsuydxhqrqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -861,7 +834,6 @@ 'model_id': 'rqhxdyusjrwxyff6', 'name': 'Smart IR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -870,8 +842,8 @@ # name: test_device_registry[6gsqieoh1yzjvxlnjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -892,7 +864,6 @@ 'model_id': 'nlxvjzy1hoeiqsg6', 'name': 'hall 💡 ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -901,8 +872,8 @@ # name: test_device_registry[6h8boeqxorpsmtj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -923,7 +894,6 @@ 'model_id': 'xqeob8h6', 'name': 'S1-TY-BLE-PRO 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -932,8 +902,8 @@ # name: test_device_registry[6o148laaosbf0g4djd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -954,7 +924,6 @@ 'model_id': 'd4g0fbsoaal841o6', 'name': 'WC D1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -963,8 +932,8 @@ # name: test_device_registry[6pd3bkidqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -985,7 +954,6 @@ 'model_id': 'dikb3dp6', 'name': 'Medidor de Energia', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -994,8 +962,8 @@ # name: test_device_registry[6tbtkuv3tal1aesfjxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1016,7 +984,6 @@ 'model_id': 'fsea1lat3vuktbt6', 'name': 'BR 7-in-1 WLAN Wetterstation Anthrazit', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1025,8 +992,8 @@ # name: test_device_registry[6wxksqu35c61sce9dsf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1047,7 +1014,6 @@ 'model_id': '9ecs16c53uqskxw6', 'name': 'ceiling fan/Light v2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1056,8 +1022,8 @@ # name: test_device_registry[73ov8i8iedtylkzrqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1078,7 +1044,6 @@ 'model_id': 'rzklytdei8i8vo37', 'name': 'balkonbewässerung', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1087,8 +1052,8 @@ # name: test_device_registry[7axah58vfydd8cphjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1109,7 +1074,6 @@ 'model_id': 'hpc8ddyfv85haxa7', 'name': 'Garage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1118,8 +1082,8 @@ # name: test_device_registry[7jxnjpiltmj2zyaijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1140,7 +1104,6 @@ 'model_id': 'iayz2jmtlipjnxj7', 'name': 'LED Porch 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1112,8 @@ # name: test_device_registry[7obpyhy8scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1171,7 +1134,6 @@ 'model_id': '8yhypbo7', 'name': 'Boîte aux lettres - arrière', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1180,8 +1142,8 @@ # name: test_device_registry[7xpq8plg06p46j7ygklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1202,7 +1164,6 @@ 'model_id': 'y7j64p60glp8qpx7', 'name': 'Fenster Küche', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1211,8 +1172,8 @@ # name: test_device_registry[7zogt3pcwhxhu8upqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1233,7 +1194,6 @@ 'model_id': 'pu8uhxhwcp3tgoz7', 'name': 'Socket3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1242,8 +1202,8 @@ # name: test_device_registry[86kdcut3hiqqddlijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1264,7 +1224,6 @@ 'model_id': 'ilddqqih3tucdk68', 'name': 'Ieskas', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1273,8 +1232,8 @@ # name: test_device_registry[87yarxyp23ap1vazjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1295,7 +1254,6 @@ 'model_id': 'zav1pa32pyxray78', 'name': 'Gengske 💡 ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1304,8 +1262,8 @@ # name: test_device_registry[8m3ggyvgycjwz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1326,7 +1284,6 @@ 'model_id': 'gvygg3m8', 'name': 'humid pelargonia', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1335,8 +1292,8 @@ # name: test_device_registry[8u5ftxkt52smougesc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1357,7 +1314,6 @@ 'model_id': 'eguoms25tkxtf5u8', 'name': 'Arida Stavern ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1366,8 +1322,8 @@ # name: test_device_registry[97k3pwirjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1388,7 +1344,6 @@ 'model_id': 'riwp3k79', 'name': 'LED KEUKEN 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1397,8 +1352,8 @@ # name: test_device_registry[9AzrW5XtELTySJxqzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1419,7 +1374,6 @@ 'model_id': 'qxJSyTLEtX5WrzA9', 'name': 'LivR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1428,8 +1382,8 @@ # name: test_device_registry[9Ry4oUpdAYq8Pe0Bkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1450,7 +1404,6 @@ 'model_id': 'B0eP8qYAdpUo4yR9', 'name': 'ITC-308-WIFI Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1459,8 +1412,8 @@ # name: test_device_registry[9c1vlsxoscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1481,7 +1434,6 @@ 'model_id': 'oxslv1c9', 'name': 'Window downstairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1490,8 +1442,8 @@ # name: test_device_registry[9oh1h1uyalfykgg4bdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1512,7 +1464,6 @@ 'model_id': '4ggkyflayu1h1ho9', 'name': 'XOCA-DAC212XC V2-S1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1521,8 +1472,8 @@ # name: test_device_registry[9wlo8cpzprhiclrkgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1543,7 +1494,6 @@ 'model_id': 'krlcihrpzpc8olw9', 'name': 'IFS-STD002', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1552,8 +1502,8 @@ # name: test_device_registry[AUTwCwqDY9EjlQSocm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1574,7 +1524,6 @@ 'model_id': 'oSQljE9YDqwCwTUA', 'name': 'Kippenluik', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1583,8 +1532,8 @@ # name: test_device_registry[CyD4ctKVrAFSSXSbjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1605,7 +1554,6 @@ 'model_id': 'bSXSSFArVKtc4DyC', 'name': 'bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1614,8 +1562,8 @@ # name: test_device_registry[HzsAAAKFLPABVi8nzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1636,7 +1584,6 @@ 'model_id': 'n8iVBAPLFKAAAszH', 'name': 'Steckdose 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1645,8 +1592,8 @@ # name: test_device_registry[JLWRUpPiwMTwKXtTtq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1667,7 +1614,6 @@ 'model_id': 'TtXKwTMwiPpURWLJ', 'name': 'Dining-Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1676,8 +1622,8 @@ # name: test_device_registry[LJ9zTFQTfMgsG2Ahzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1698,7 +1644,6 @@ 'model_id': 'hA2GsgMfTQFTz9JL', 'name': 'Spot 4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1707,8 +1652,8 @@ # name: test_device_registry[LS6FfVBVU1vzBRBHzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1729,7 +1674,6 @@ 'model_id': 'HBRBzv1UVBVfF6SL', 'name': 'Rewireable Plug 6930HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1738,8 +1682,8 @@ # name: test_device_registry[LmLMc0ht1KW2zYAIkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1760,7 +1704,6 @@ 'model_id': 'IAYz2WK1th0cMLmL', 'name': 'El termostato de la cocina', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1769,8 +1712,8 @@ # name: test_device_registry[NVjuXIQ6QH9eZLHCzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1791,7 +1734,6 @@ 'model_id': 'CHLZe9HQ6QIXujVN', 'name': 'schuur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1800,8 +1742,8 @@ # name: test_device_registry[O8QpxJwdme33sqn4gk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1822,7 +1764,6 @@ 'model_id': '4nqs33emdwJxpQ8O', 'name': 'office lights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1831,8 +1772,8 @@ # name: test_device_registry[VA4QyBNZHkJ2Xa4hjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1853,7 +1794,6 @@ 'model_id': 'h4aX2JkHZNByQ4AV', 'name': 'Entry Stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1862,8 +1802,8 @@ # name: test_device_registry[YQLkAe7nyyAxXHiAzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1884,7 +1824,6 @@ 'model_id': 'AiHXxAyyn7eAkLQY', 'name': 'Solar Heater Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1893,8 +1832,8 @@ # name: test_device_registry[ZDldMHS0tjmQgGxEzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1915,7 +1854,6 @@ 'model_id': 'ExGgQmjt0SHMdlDZ', 'name': 'Casa1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1924,8 +1862,8 @@ # name: test_device_registry[ZgXzZULP6dDp4Atvgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1946,7 +1884,6 @@ 'model_id': 'vtA4pDd6PLUZzXgZ', 'name': 'Humy bain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1955,8 +1892,8 @@ # name: test_device_registry[a3qtb7pulkcc6jdjqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1977,7 +1914,6 @@ 'model_id': 'jdj6ccklup7btq3a', 'name': 'Eau Chaude', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1986,8 +1922,8 @@ # name: test_device_registry[a4zeazrz1ata9mbggk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2008,7 +1944,6 @@ 'model_id': 'gbm9ata1zrzaez4a', 'name': 'QT-Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2017,8 +1952,8 @@ # name: test_device_registry[a6ugbo3of3hqf4jojd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2039,7 +1974,6 @@ 'model_id': 'oj4fqh3fo3obgu6a', 'name': 'L├ímpara Ati', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2048,8 +1982,8 @@ # name: test_device_registry[aa99hccfnzvypr3zjsywc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2070,7 +2004,6 @@ 'model_id': 'z3rpyvznfcch99aa', 'name': 'PIXI Smart Drinking Fountain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2079,8 +2012,8 @@ # name: test_device_registry[addr6y4u8gb43nl8brnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2101,7 +2034,6 @@ 'model_id': '8ln34bg8u4y6rdda', 'name': 'Madimack Elite V3 Pool Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2110,8 +2042,8 @@ # name: test_device_registry[ai9swgb6tyinbwbxjxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2132,7 +2064,6 @@ 'model_id': 'xbwbniyt6bgws9ia', 'name': 'SWS 16600 WiFi SH', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2141,8 +2072,8 @@ # name: test_device_registry[aiag5pku0x39rkfllc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2163,7 +2094,6 @@ 'model_id': 'lfkr93x0ukp5gaia', 'name': 'Projector Screen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2172,8 +2102,8 @@ # name: test_device_registry[aje5kxgmhhxdihqizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2194,7 +2124,6 @@ 'model_id': 'iqhidxhhmgxk5eja', 'name': 'Powerplug 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2203,8 +2132,8 @@ # name: test_device_registry[ajkdo1bm2rcmpuufjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2225,7 +2154,6 @@ 'model_id': 'fuupmcr2mb1odkja', 'name': 'Slaapkamer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2234,8 +2162,8 @@ # name: test_device_registry[ake0bre784zriw0usc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2256,7 +2184,6 @@ 'model_id': 'u0wirz487erb0eka', 'name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2265,8 +2192,8 @@ # name: test_device_registry[ao3z3oeyvepe8o3xqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2287,7 +2214,6 @@ 'model_id': 'x3o8epevyeo3z3oa', 'name': 'Interior Bedroom Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2296,8 +2222,8 @@ # name: test_device_registry[aoyweq8xbx7qfndijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2318,7 +2244,6 @@ 'model_id': 'idnfq7xbx8qewyoa', 'name': 'AB1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2327,8 +2252,8 @@ # name: test_device_registry[ase6htln9tdni2sijxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2349,7 +2274,6 @@ 'model_id': 'is2indt9nlth6esa', 'name': 'Frysen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2358,8 +2282,8 @@ # name: test_device_registry[b6e05dfy4qhpgea1qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2380,7 +2304,6 @@ 'model_id': '1aegphq4yfd50e6b', 'name': 'jardin Fraises', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2389,8 +2312,8 @@ # name: test_device_registry[bFFsO8HimyAJGIj7scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2411,7 +2334,6 @@ 'model_id': '7jIGJAymiH8OsFFb', 'name': 'Door Garage ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2420,8 +2342,8 @@ # name: test_device_registry[bak2crzmabancwqvjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2442,7 +2364,6 @@ 'model_id': 'vqwcnabamzrc2kab', 'name': 'Strip 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2451,8 +2372,8 @@ # name: test_device_registry[bcyciyhhu1g2gk9rqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2473,7 +2394,6 @@ 'model_id': 'r9kg2g1uhhyicycb', 'name': 'P1 Energia Elettrica', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2482,8 +2402,8 @@ # name: test_device_registry[bescacsciyam3aouqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2504,7 +2424,6 @@ 'model_id': 'uoa3mayicscacseb', 'name': 'Living room left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2513,8 +2432,8 @@ # name: test_device_registry[bfpewgk8r6fhmissdyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2535,7 +2454,6 @@ 'model_id': 'ssimhf6r8kgwepfb', 'name': 'BlissRadia ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2544,8 +2462,8 @@ # name: test_device_registry[bgnj6bafrdgb1xmajd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2566,7 +2484,6 @@ 'model_id': 'amx1bgdrfab6jngb', 'name': 'Lumy Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2575,8 +2492,8 @@ # name: test_device_registry[bjum5isf7h6xpbrvzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2597,7 +2514,6 @@ 'model_id': 'vrbpx6h7fsi5mujb', 'name': '接HA双向计量插座', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2606,8 +2522,8 @@ # name: test_device_registry[bl5cuqxnqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2628,7 +2544,6 @@ 'model_id': 'nxquc5lb', 'name': 'Smart Water Timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2637,8 +2552,8 @@ # name: test_device_registry[btpss2f6kwfi294rqsj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2659,7 +2574,6 @@ 'model_id': 'r492ifwk6f2ssptb', 'name': 'KLARTA HUMEA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2668,8 +2582,8 @@ # name: test_device_registry[btyk53n3v10z7a97zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2690,7 +2604,6 @@ 'model_id': '79a7z01v3n35kytb', 'name': 'Double Digital Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2699,8 +2612,8 @@ # name: test_device_registry[buzituffc13pgb1jjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2721,7 +2634,6 @@ 'model_id': 'j1bgp31cffutizub', 'name': 'Ceiling Portal', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2730,8 +2642,8 @@ # name: test_device_registry[bxfkpxjgux2fgwnazc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2752,7 +2664,6 @@ 'model_id': 'anwgf2xugjxpkfxb', 'name': 'Security Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2761,8 +2672,8 @@ # name: test_device_registry[c1tfgunpf6optybisf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2783,7 +2694,6 @@ 'model_id': 'ibytpo6fpnugft1c', 'name': 'Ventilador Cama', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2792,8 +2702,8 @@ # name: test_device_registry[c9nbmrweturkgqktdyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2814,7 +2724,6 @@ 'model_id': 'tkqgkrutewrmbn9c', 'name': 'White Noise Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2823,8 +2732,8 @@ # name: test_device_registry[cd6bezcadvjngj5jrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2845,7 +2754,6 @@ 'model_id': 'j5jgnjvdaczeb6dc', 'name': 'QNECT WI-FI PIR SENSOR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2854,8 +2762,8 @@ # name: test_device_registry[cijerqyssiwrf7deqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2876,7 +2784,6 @@ 'model_id': 'ed7frwissyqrejic', 'name': '接HA水阀', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2885,8 +2792,8 @@ # name: test_device_registry[cju47ovcbeuapei2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2907,7 +2814,6 @@ 'model_id': '2iepauebcvo74ujc', 'name': 'Aubess Cooker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2916,8 +2822,8 @@ # name: test_device_registry[codvtvgtjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2938,7 +2844,6 @@ 'model_id': 'tgvtvdoc', 'name': 'Tournesol', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2947,8 +2852,8 @@ # name: test_device_registry[couukaypjdnyt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2969,7 +2874,6 @@ 'model_id': 'pyakuuoc', 'name': 'Solar zijpad', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2978,8 +2882,8 @@ # name: test_device_registry[cq4hzlrnqn4qi0mqzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3000,7 +2904,6 @@ 'model_id': 'qm0iq4nqnrlzh4qc', 'name': 'Elivco Kitchen Socket', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3009,8 +2912,8 @@ # name: test_device_registry[cvowstbid97lokayjb2oc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3031,7 +2934,6 @@ 'model_id': 'yakol79dibtswovc', 'name': 'PTH-9CW 32', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3040,8 +2942,8 @@ # name: test_device_registry[cwwk68dyfsh2eqi4jbqr] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3062,7 +2964,6 @@ 'model_id': '4iqe2hsfyd86kwwc', 'name': 'Gas sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3071,8 +2972,8 @@ # name: test_device_registry[cxbmhihohohk5bmeqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3093,7 +2994,6 @@ 'model_id': 'emb5khohohihmbxc', 'name': 'Server Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3102,8 +3002,8 @@ # name: test_device_registry[dBFBdywk9gTihUQmzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3124,7 +3024,6 @@ 'model_id': 'mQUhiTg9kwydBFBd', 'name': 'Waschmaschine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3133,8 +3032,8 @@ # name: test_device_registry[dNBnmtjLU8eRWHf0zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3155,7 +3054,6 @@ 'model_id': '0fHWRe8ULjtmnBNd', 'name': 'Weihnachten3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3164,8 +3062,8 @@ # name: test_device_registry[dj8foneugkjc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3186,7 +3084,6 @@ 'model_id': 'uenof8jd', 'name': 'Smart Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3195,8 +3092,8 @@ # name: test_device_registry[dke76hazlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3217,7 +3114,6 @@ 'model_id': 'zah67ekd', 'name': 'Kitchen Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3226,8 +3122,8 @@ # name: test_device_registry[dn7cjik6kw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3248,7 +3144,6 @@ 'model_id': '6kijc7nd', 'name': 'Кабінет', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3257,8 +3152,8 @@ # name: test_device_registry[dt4whlrosmnldadvtk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3279,7 +3174,6 @@ 'model_id': 'vdadlnmsorlhw4td', 'name': 'Sove', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3288,8 +3182,8 @@ # name: test_device_registry[dvdtmcoil5yopaljjzm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3310,7 +3204,6 @@ 'model_id': 'jlapoy5liocmtdvd', 'name': 'ISV-100W2.0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3319,8 +3212,8 @@ # name: test_device_registry[e2sbdwuga5jorvejtkdy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3341,7 +3234,6 @@ 'model_id': 'jevroj5aguwdbs2e', 'name': 'DOLCECLIMA 10 HP WIFI', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3350,8 +3242,8 @@ # name: test_device_registry[ej2zsznihehztkzqcaderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3372,7 +3264,6 @@ 'model_id': 'qzktzhehinzsz2je', 'name': 'Air', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3381,8 +3272,8 @@ # name: test_device_registry[eway2kw92ncuecarzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3403,7 +3294,6 @@ 'model_id': 'raceucn29wk2yawe', 'name': 'Bathroom Mirror', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3412,8 +3302,8 @@ # name: test_device_registry[f4vvhmhvseuiqs6pqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3434,7 +3324,6 @@ 'model_id': 'p6sqiuesvhmhvv4f', 'name': 'Entrance Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3443,8 +3332,8 @@ # name: test_device_registry[fasvixqysw1lxvjprd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3465,7 +3354,6 @@ 'model_id': 'pjvxl1wsyqxivsaf', 'name': 'Sunbeam Bedding', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3474,8 +3362,8 @@ # name: test_device_registry[fbya6s6rhaoyvl8hqgcwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3496,7 +3384,6 @@ 'model_id': 'h8lvyoahr6s6aybf', 'name': 'Rainwater Tank Level', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3505,8 +3392,8 @@ # name: test_device_registry[fc2ngmpckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3527,7 +3414,6 @@ 'model_id': 'cpmgn2cf', 'name': 'Bathroom radiator', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3536,8 +3422,8 @@ # name: test_device_registry[fcacn8iqbocuow7dsr] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3558,7 +3444,6 @@ 'model_id': 'd7woucobqi8ncacf', 'name': 'Geti Solar PV Water Heater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3567,8 +3452,8 @@ # name: test_device_registry[fcdadqsiax2gvnt0qld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3589,7 +3474,6 @@ 'model_id': '0tnvg2xaisqdadcf', 'name': '一路带计量磁保持通断器', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3598,8 +3482,8 @@ # name: test_device_registry[fjdyw5ld2f5f5ddsps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3620,7 +3504,6 @@ 'model_id': 'sdd5f5f2dl5wydjf', 'name': 'C9', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3629,8 +3512,8 @@ # name: test_device_registry[fov1huugujgfyl0xqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3651,7 +3534,6 @@ 'model_id': 'x0lyfgjuguuh1vof', 'name': 'Smart IR+RF Remote Control', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3660,8 +3542,8 @@ # name: test_device_registry[frmfrbds0jixxyaljbngd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3682,7 +3564,6 @@ 'model_id': 'layxxij0sdbrfmrf', 'name': 'WiFi smart online 8 in 1 tester', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3691,8 +3572,8 @@ # name: test_device_registry[ftvxinxevpy21tbelc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3713,7 +3594,6 @@ 'model_id': 'ebt12ypvexnixvtf', 'name': 'Kitchen Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3722,8 +3602,8 @@ # name: test_device_registry[fvywp3b5mu4zay8lgkxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3744,7 +3624,6 @@ 'model_id': 'l8yaz4um5b3pwyvf', 'name': 'Bathroom Smart Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3753,8 +3632,8 @@ # name: test_device_registry[g0edqq0wzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3775,7 +3654,6 @@ 'model_id': 'w0qqde0g', 'name': 'Lave linge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3784,8 +3662,8 @@ # name: test_device_registry[g1efxsqnp33cg8r3lc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3806,7 +3684,6 @@ 'model_id': '3r8gc33pnqsxfe1g', 'name': 'Lounge Dark Blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3815,8 +3692,8 @@ # name: test_device_registry[g1fmm26qhhrimmbitk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3837,7 +3714,6 @@ 'model_id': 'ibmmirhhq62mmf1g', 'name': 'Master Bedroom AC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3846,8 +3722,8 @@ # name: test_device_registry[g1qorlffoy2iyo9bsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3868,7 +3744,6 @@ 'model_id': 'b9oyi2yofflroq1g', 'name': 'Living room dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3877,8 +3752,8 @@ # name: test_device_registry[g5uso5ajgkxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3899,7 +3774,6 @@ 'model_id': 'ja5osu5g', 'name': 'Bouton tempo extérieur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3908,8 +3782,8 @@ # name: test_device_registry[g7af6lrt4miugbstcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3930,7 +3804,6 @@ 'model_id': 'tsbguim4trl6fa7g', 'name': 'Keller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3939,8 +3812,8 @@ # name: test_device_registry[g9h9sblxpb5wdwzkqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3961,7 +3834,6 @@ 'model_id': 'kzwdw5bpxlbs9h9g', 'name': 'IR Minero', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3970,8 +3842,8 @@ # name: test_device_registry[gbq8kiahk57ct0bpncjynx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3992,7 +3864,6 @@ 'model_id': 'pb0tc75khaik8qbg', 'name': 'CBE Pro 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4001,8 +3872,8 @@ # name: test_device_registry[ggimpv4dqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4023,7 +3894,6 @@ 'model_id': 'd4vpmigg', 'name': 'Garden Valve Yard', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4032,8 +3902,8 @@ # name: test_device_registry[ggwxkj8bwn5y63flgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4054,7 +3924,6 @@ 'model_id': 'lf36y5nwb8jkxwgg', 'name': 'Greenhouse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4063,8 +3932,8 @@ # name: test_device_registry[gi69tunb0esxcnefzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4085,7 +3954,6 @@ 'model_id': 'fencxse0bnut96ig', 'name': 'Spa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4094,8 +3962,8 @@ # name: test_device_registry[giqs1xhsekjelfibsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4116,7 +3984,6 @@ 'model_id': 'biflejkeshx1sqig', 'name': 'D825A I', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4125,8 +3992,8 @@ # name: test_device_registry[gjnpc0eojd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4147,7 +4014,6 @@ 'model_id': 'oe0cpnjg', 'name': 'Front right Lighting trap', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4156,8 +4022,8 @@ # name: test_device_registry[glsehgu8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4178,7 +4044,6 @@ 'model_id': '8ugheslg', 'name': 'POWERASIA R2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4187,8 +4052,8 @@ # name: test_device_registry[gluaktf5gk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4209,7 +4074,6 @@ 'model_id': '5ftkaulg', 'name': 'bathroom light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4218,8 +4082,8 @@ # name: test_device_registry[gm0whbftkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4240,7 +4104,6 @@ 'model_id': 'tfbhw0mg', 'name': 'Salon', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4249,8 +4112,8 @@ # name: test_device_registry[gnZOKztbAtcBkEGPzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4271,7 +4134,6 @@ 'model_id': 'PGEkBctAbtzKOZng', 'name': 'Din', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4280,8 +4142,8 @@ # name: test_device_registry[gnqwzcph94wj2sl5nq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4302,7 +4164,6 @@ 'model_id': '5ls2jw49hpczwqng', 'name': 'Mr. Pure', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4311,8 +4172,8 @@ # name: test_device_registry[gt1q9tldv1opojrtcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4333,7 +4194,6 @@ 'model_id': 'trjopo1vdlt9q1tg', 'name': 'Terras', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4342,8 +4202,8 @@ # name: test_device_registry[gtcinipmdp5rgx3nlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4364,7 +4224,6 @@ 'model_id': 'n3xgr5pdmpinictg', 'name': 'Estore Sala', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4373,8 +4232,8 @@ # name: test_device_registry[gvxxy4jitzltz5xhscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4395,7 +4254,6 @@ 'model_id': 'hx5ztlztij4yxxvg', 'name': 'Steel cage door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4404,8 +4262,8 @@ # name: test_device_registry[hfqeljop3aihnm73zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4426,7 +4284,6 @@ 'model_id': '37mnhia3pojleqfh', 'name': 'Sapphire ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4435,8 +4292,8 @@ # name: test_device_registry[hkm4px9ohzozxma3rip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4457,7 +4314,6 @@ 'model_id': '3amxzozho9xp4mkh', 'name': 'rat trap hedge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4466,8 +4322,8 @@ # name: test_device_registry[hxbonj4yzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4488,7 +4344,6 @@ 'model_id': 'y4jnobxh', 'name': 'AuVeLiCo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4497,8 +4352,8 @@ # name: test_device_registry[hyda5jsihokacvaqjzm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4519,7 +4374,6 @@ 'model_id': 'qavcakohisj5adyh', 'name': 'Sous Vide', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4528,8 +4382,8 @@ # name: test_device_registry[hz4pau766eavmxhqsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4550,7 +4404,6 @@ 'model_id': 'qhxmvae667uap4zh', 'name': 'DryFix', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4559,8 +4412,8 @@ # name: test_device_registry[i6xywcsymer1kmb6ps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4581,7 +4434,6 @@ 'model_id': '6bmk1remyscwyx6i', 'name': 'Mirilla puerta', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4590,8 +4442,8 @@ # name: test_device_registry[iaagy4qigcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4612,7 +4464,6 @@ 'model_id': 'iq4ygaai', 'name': 'Bassin', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4621,8 +4472,8 @@ # name: test_device_registry[idztlaspsms815moqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4643,7 +4494,6 @@ 'model_id': 'om518smspsaltzdi', 'name': 'Smart IR Theater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4652,8 +4502,8 @@ # name: test_device_registry[ifzgvpgoodrfw2aksc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4674,7 +4524,6 @@ 'model_id': 'ka2wfrdoogpvgzfi', 'name': 'Dehumidifer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4683,8 +4532,8 @@ # name: test_device_registry[igkrtodqg14xvfxlqswwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4705,7 +4554,6 @@ 'model_id': 'lxfvx41gqdotrkgi', 'name': 'Cat Feeder', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4714,8 +4562,8 @@ # name: test_device_registry[ijne16zv8vpqmubnjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4736,7 +4584,6 @@ 'model_id': 'nbumqpv8vz61enji', 'name': 'b2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4745,8 +4592,8 @@ # name: test_device_registry[ijzjlqwmv1blwe0gsf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4767,7 +4614,6 @@ 'model_id': 'g0ewlb1vmwqljzji', 'name': 'Ceiling Fan With Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4776,8 +4622,8 @@ # name: test_device_registry[ikbbdbnqsd70pc1glc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4798,7 +4644,6 @@ 'model_id': 'g1cp07dsqnbdbbki', 'name': 'Persiana do Quarto', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4807,8 +4652,8 @@ # name: test_device_registry[iks13mcaiyie3rryjb2oc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4829,7 +4674,6 @@ 'model_id': 'yrr3eiyiacm31ski', 'name': 'AQI', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4838,8 +4682,8 @@ # name: test_device_registry[ilms5pwjzzsxuxmvsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4860,7 +4704,6 @@ 'model_id': 'vmxuxszzjwp5smli', 'name': 'Dehumidifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4869,8 +4712,8 @@ # name: test_device_registry[im3fum2zt73boagkjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4891,7 +4734,6 @@ 'model_id': 'kgaob37tz2muf3mi', 'name': 'Parker Ceiling Fan 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4900,8 +4742,8 @@ # name: test_device_registry[ingdwog22gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4922,7 +4764,6 @@ 'model_id': '2gowdgni', 'name': 'Mesh-Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4931,8 +4772,8 @@ # name: test_device_registry[iomszlsve0yyzkfwqswwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4953,7 +4794,6 @@ 'model_id': 'wfkzyy0evslzsmoi', 'name': 'Cleverio PF100', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4962,8 +4802,8 @@ # name: test_device_registry[j6mn1t4ut5end6ifkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4984,7 +4824,6 @@ 'model_id': 'fi6dne5tu4t1nm6j', 'name': 'WiFi Smart Gas Boiler Thermostat ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4993,8 +4832,8 @@ # name: test_device_registry[jfpdpavoqgoqsn3cjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5015,7 +4854,6 @@ 'model_id': 'c3nsqogqovapdpfj', 'name': 'Arbeitszimmer led', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5024,8 +4862,8 @@ # name: test_device_registry[jfydgffzmhjed9fgjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5046,7 +4884,6 @@ 'model_id': 'gf9dejhmzffgdyfj', 'name': ' Smoke detector upstairs ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5055,8 +4892,8 @@ # name: test_device_registry[jgsopsvzh2ec3itjzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5077,7 +4914,6 @@ 'model_id': 'jti3ce2hzvsposgj', 'name': 'Dehumidifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5086,8 +4922,8 @@ # name: test_device_registry[jlduh7vigcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5108,7 +4944,6 @@ 'model_id': 'iv7hudlj', 'name': 'Basement temperature', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5117,8 +4952,8 @@ # name: test_device_registry[jm2fsqtzuhqtbo5ykw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5139,7 +4974,6 @@ 'model_id': 'y5obtqhuztqsf2mj', 'name': 'Term - Prizemi', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5148,8 +4982,8 @@ # name: test_device_registry[jzpap0inhkykqtlwgklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5170,7 +5004,6 @@ 'model_id': 'wltqkykhni0papzj', 'name': 'Roller shutter Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5179,8 +5012,8 @@ # name: test_device_registry[kcdngswaxs8hm52bnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5201,7 +5034,6 @@ 'model_id': 'b25mh8sxawsgndck', 'name': 'ZigBee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5210,8 +5042,8 @@ # name: test_device_registry[kffnst1epj6vr8xnzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5232,7 +5064,6 @@ 'model_id': 'nx8rv6jpe1tsnffk', 'name': 'Spot 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5241,8 +5072,8 @@ # name: test_device_registry[kjr0pqg7eunn4vlujbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5263,7 +5094,6 @@ 'model_id': 'ulv4nnue7gqp0rjk', 'name': 'Siren veranda ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5272,8 +5102,8 @@ # name: test_device_registry[kkande5hk6sfdkoxjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5294,7 +5124,6 @@ 'model_id': 'xokdfs6kh5ednakk', 'name': 'ERKER 1-Gold ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5303,8 +5132,8 @@ # name: test_device_registry[kkcwqzlvgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5325,7 +5154,6 @@ 'model_id': 'vlzqwckk', 'name': 'Temperature Humidity Sensor abelhas pasillo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5334,8 +5162,8 @@ # name: test_device_registry[kkgbskmfejn67l1orip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5356,7 +5184,6 @@ 'model_id': 'o1l76njefmksbgkk', 'name': 'PIR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5365,8 +5192,8 @@ # name: test_device_registry[klgxmpwvdhw7tzs8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5387,7 +5214,6 @@ 'model_id': '8szt7whdvwpmxglk', 'name': 'Porch light E', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5396,8 +5222,8 @@ # name: test_device_registry[ksy8guiy64acbbpnqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5418,7 +5244,6 @@ 'model_id': 'npbbca46yiug8ysk', 'name': 'Bedroom IR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5427,8 +5252,8 @@ # name: test_device_registry[kta28zbwj6u0xa6lbsgy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5449,7 +5274,6 @@ 'model_id': 'l6ax0u6jwbz82atk', 'name': 'Pond', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5458,8 +5282,8 @@ # name: test_device_registry[kvnsoqyfltmf0bknzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5480,7 +5304,6 @@ 'model_id': 'nkb0fmtlfyqosnvk', 'name': 'Bassin', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5489,8 +5312,8 @@ # name: test_device_registry[kx8dncf1qzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5511,7 +5334,6 @@ 'model_id': '1fcnd8xk', 'name': 'Valve Controller 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5520,8 +5342,8 @@ # name: test_device_registry[kxwleaa2sph] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5542,7 +5364,6 @@ 'model_id': '2aaelwxk', 'name': 'Human presence Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5551,8 +5372,8 @@ # name: test_device_registry[kxxrbv93k2vvkconqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5573,7 +5394,6 @@ 'model_id': 'nockvv2k39vbrxxk', 'name': 'Seating side 6-ch Smart Switch ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5582,8 +5402,8 @@ # name: test_device_registry[l8uxezzkc7c5a0jhzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5604,7 +5424,6 @@ 'model_id': 'hj0a5c7ckzzexu8l', 'name': 'droger', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5613,8 +5432,8 @@ # name: test_device_registry[lflvu8cazha8af9jsk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5635,7 +5454,6 @@ 'model_id': 'j9fa8ahzac8uvlfl', 'name': 'Tower Fan CA-407G Smart', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5644,8 +5462,8 @@ # name: test_device_registry[llw1rhcau4y3othdzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5666,7 +5484,6 @@ 'model_id': 'dhto3y4uachr1wll', 'name': 'Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5675,8 +5492,8 @@ # name: test_device_registry[lnjsbx45z3p7s59zbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5697,7 +5514,6 @@ 'model_id': 'z95s7p3z54xbsjnl', 'name': 'WIFI Dual Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5706,8 +5522,8 @@ # name: test_device_registry[mgcpxpmovasazerdps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5728,7 +5544,6 @@ 'model_id': 'drezasavompxpcgm', 'name': 'CAM GARAGE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5737,8 +5552,8 @@ # name: test_device_registry[mjhwalv51czt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5759,7 +5574,6 @@ 'model_id': '5vlawhjm', 'name': 'INTELAR IR288', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5768,8 +5582,8 @@ # name: test_device_registry[mpowx36sgqexmtes2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5790,7 +5604,6 @@ 'model_id': 'setmxeqgs63xwopm', 'name': 'Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5799,8 +5612,8 @@ # name: test_device_registry[mvsdcwtskkezlnw5tk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5821,7 +5634,6 @@ 'model_id': '5wnlzekkstwcdsvm', 'name': 'Air Conditioner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5830,8 +5642,8 @@ # name: test_device_registry[mwsaod7fa3gjyh6ids] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5852,7 +5664,6 @@ 'model_id': 'i6hyjg3af7doaswm', 'name': 'Hoover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5861,8 +5672,8 @@ # name: test_device_registry[nc4e9nlZPTuTNfYEzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5883,7 +5694,6 @@ 'model_id': 'EYfNTuTPZln9e4cn', 'name': 'ZAS-01', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5892,8 +5702,8 @@ # name: test_device_registry[ncl7oi5d6hqmf1g0zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5914,7 +5724,6 @@ 'model_id': '0g1fmqh6d5io7lcn', 'name': 'Apollo light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5923,8 +5732,8 @@ # name: test_device_registry[ngcubvaqoraolsmtjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5945,7 +5754,6 @@ 'model_id': 'tmsloaroqavbucgn', 'name': 'Pokerlamp 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5954,8 +5762,8 @@ # name: test_device_registry[nnqlg0rxryraf8ezbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5976,7 +5784,6 @@ 'model_id': 'ze8faryrxr0glqnn', 'name': 'Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5985,8 +5792,8 @@ # name: test_device_registry[nr26obpclc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6007,7 +5814,6 @@ 'model_id': 'cpbo62rn', 'name': 'blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6016,8 +5822,8 @@ # name: test_device_registry[nt3mpibadxfqkegldyg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6038,7 +5844,6 @@ 'model_id': 'lgekqfxdabipm3tn', 'name': 'Colorful PIR Night Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6047,8 +5852,8 @@ # name: test_device_registry[nxdcy0uidplnhkazjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6069,7 +5874,6 @@ 'model_id': 'zakhnlpdiu0ycdxn', 'name': 'Stoel', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6078,8 +5882,8 @@ # name: test_device_registry[nyriu7sjgj9oruzmpsm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6100,7 +5904,6 @@ 'model_id': 'mzuro9jgjs7uiryn', 'name': 'Poopy Nano 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6109,8 +5912,8 @@ # name: test_device_registry[o4hpbl5uarjfbzheps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6131,7 +5934,6 @@ 'model_id': 'ehzbfjrau5lbph4o', 'name': 'Dolní vchod - západ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6140,8 +5942,8 @@ # name: test_device_registry[o5kqedcacfng0plpnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6162,7 +5964,6 @@ 'model_id': 'plp0gnfcacdeqk5o', 'name': 'Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6171,8 +5972,8 @@ # name: test_device_registry[o71einxvuuktuljcjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6193,7 +5994,6 @@ 'model_id': 'cjlutkuuvxnie17o', 'name': 'Rauchmelder Alexsandro ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6202,8 +6002,8 @@ # name: test_device_registry[obb7p55c0us6rdxkqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6224,7 +6024,6 @@ 'model_id': 'kxdr6su0c55p7bbo', 'name': 'Metering_3PN_WiFi_stable', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6233,8 +6032,8 @@ # name: test_device_registry[ohefbbk9gcdl] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6255,7 +6054,6 @@ 'model_id': '9kbbfeho', 'name': 'Luminosité', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6264,8 +6062,8 @@ # name: test_device_registry[okwwus27jhqqe2mijbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6286,7 +6084,6 @@ 'model_id': 'im2eqqhj72suwwko', 'name': 'Siren', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6295,8 +6092,8 @@ # name: test_device_registry[ol8xwtcj42eg18bdbrnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6317,7 +6114,6 @@ 'model_id': 'db81ge24jctwx8lo', 'name': 'Hot Water Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6326,8 +6122,8 @@ # name: test_device_registry[oq9ksabjz6tip49tkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6348,7 +6144,6 @@ 'model_id': 't94pit6zjbask9qo', 'name': 'Floor Thermostat Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6357,8 +6152,8 @@ # name: test_device_registry[oqyhsaqwsph] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6379,7 +6174,6 @@ 'model_id': 'wqashyqo', 'name': 'Soil moisture sensor #1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6388,8 +6182,8 @@ # name: test_device_registry[orotles4ucq8rxwn2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6410,7 +6204,6 @@ 'model_id': 'nwxr8qcu4seltoro', 'name': 'X5 Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6419,8 +6212,8 @@ # name: test_device_registry[ouabwhlarnczogyfqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6441,7 +6234,6 @@ 'model_id': 'fygozcnralhwbauo', 'name': 'SPM02_WiFi', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6450,8 +6242,8 @@ # name: test_device_registry[owozxdzgbibizu4sjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6472,7 +6264,6 @@ 'model_id': 's4uzibibgzdxzowo', 'name': 'ION1000PRO', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6481,8 +6272,8 @@ # name: test_device_registry[oxi73pj9a0ubr60pjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6503,7 +6294,6 @@ 'model_id': 'p06rbu0a9jp37ixo', 'name': 'Jardim Casa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6512,8 +6302,8 @@ # name: test_device_registry[p2gnclbiqxrbboagdd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6534,7 +6324,6 @@ 'model_id': 'gaobbrxqiblcng2p', 'name': 'TV Sync Backlights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6543,8 +6332,8 @@ # name: test_device_registry[p5ger7bqlcjtmmqgbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6565,7 +6354,6 @@ 'model_id': 'gqmmtjclqb7reg5p', 'name': 'Wi-Fi Meter(Bi-Directional)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6574,8 +6362,8 @@ # name: test_device_registry[p8xoxccrjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6596,7 +6384,6 @@ 'model_id': 'rccxox8p', 'name': 'Smoke Alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6605,8 +6392,8 @@ # name: test_device_registry[paxijfx9fkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6627,7 +6414,6 @@ 'model_id': '9xfjixap', 'name': 'Empore', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6636,8 +6422,8 @@ # name: test_device_registry[pdasfna8fswh4a0tzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6658,7 +6444,6 @@ 'model_id': 't0a4hwsf8anfsadp', 'name': 'wallwasher front', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6667,8 +6452,8 @@ # name: test_device_registry[pdnimgsb3w0xko3kjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6689,7 +6474,6 @@ 'model_id': 'k3okx0w3bsgmindp', 'name': 'Portal Casa Carro Jalimy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6698,8 +6482,8 @@ # name: test_device_registry[pfhwb1v3i7cifa2tcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6720,7 +6504,6 @@ 'model_id': 't2afic7i3v1bwhfp', 'name': 'Bubbelbad', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6729,8 +6512,8 @@ # name: test_device_registry[ppgdpsq1xaxlyzryjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6751,7 +6534,6 @@ 'model_id': 'yrzylxax1qspdgpp', 'name': 'Bree', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6760,8 +6542,8 @@ # name: test_device_registry[pykascx9yfqrxtbgzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6782,7 +6564,6 @@ 'model_id': 'gbtxrqfy9xcsakyp', 'name': '3DPrinter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6791,8 +6572,8 @@ # name: test_device_registry[pz2xuth8hczv6zrwzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6813,7 +6594,6 @@ 'model_id': 'wrz6vzch8htux2zp', 'name': 'Elivco TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6822,8 +6602,8 @@ # name: test_device_registry[q304vac40br8nlkajsywc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6844,7 +6624,6 @@ 'model_id': 'akln8rb04cav403q', 'name': 'Water Fountain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6853,8 +6632,8 @@ # name: test_device_registry[q3iie9vjd4wfqyy1qzkmkc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6875,7 +6654,6 @@ 'model_id': '1yyqfw4djv9eii3q', 'name': 'Garage door ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6884,8 +6662,8 @@ # name: test_device_registry[q62sg0p3s52thp6zzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6906,7 +6684,6 @@ 'model_id': 'z6pht25s3p0gs26q', 'name': '6294HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6915,8 +6692,8 @@ # name: test_device_registry[q8dncqpgin4yympisc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6937,7 +6714,6 @@ 'model_id': 'ipmyy4nigpqcnd8q', 'name': 'Pro Breeze 30L Compressor Dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6946,8 +6722,8 @@ # name: test_device_registry[qe8vvtx4nl21wjd3dytkx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6968,7 +6744,6 @@ 'model_id': '3djw12ln4xtvv8eq', 'name': 'Genio Nebula & Blue Star Projector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6977,8 +6752,8 @@ # name: test_device_registry[qhgghufzqtwloqoqjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6999,7 +6774,6 @@ 'model_id': 'qoqolwtqzfuhgghq', 'name': 'Smart Bulb RGBCW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7008,8 +6782,8 @@ # name: test_device_registry[qi94v9dmdx4fkpncqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7030,7 +6804,6 @@ 'model_id': 'cnpkf4xdmd9v49iq', 'name': '断路器HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7039,8 +6812,8 @@ # name: test_device_registry[qifhbafbqubbp3b6qbnnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7061,7 +6834,6 @@ 'model_id': '6b3pbbuqbfabhfiq', 'name': 'Wi-Fi solar grid micro inverter (GT)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7070,8 +6842,8 @@ # name: test_device_registry[qt0o5jlatiqf2rscps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7092,7 +6864,6 @@ 'model_id': 'csr2fqitalj5o0tq', 'name': 'Intercom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7101,8 +6872,8 @@ # name: test_device_registry[queafegmhhmtivdxjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7123,7 +6894,6 @@ 'model_id': 'xdvitmhhmgefaeuq', 'name': 'druckerhell', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7132,8 +6902,8 @@ # name: test_device_registry[qwExlkou9h2USezrjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7154,7 +6924,6 @@ 'model_id': 'rzeSU2h9uoklxEwq', 'name': 'Inondation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7163,8 +6932,8 @@ # name: test_device_registry[qyy1auihjyoogvb7zdccq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7185,7 +6954,6 @@ 'model_id': '7bvgooyjhiua1yyq', 'name': 'AC charging control box', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7194,8 +6962,8 @@ # name: test_device_registry[r4yrlr705ei31ikmjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7216,7 +6984,6 @@ 'model_id': 'mki13ie507rlry4r', 'name': 'Garage light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7225,8 +6992,8 @@ # name: test_device_registry[rdq0bn4dzuwx2qfujd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7247,7 +7014,6 @@ 'model_id': 'ufq2xwuzd4nb0qdr', 'name': 'Sjiethoes', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7256,8 +7022,8 @@ # name: test_device_registry[ri7eegdifufzdi54dyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7278,7 +7044,6 @@ 'model_id': '45idzfufidgee7ir', 'name': 'Smart White Noise Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7287,8 +7052,8 @@ # name: test_device_registry[rirsc4vhpbv2whkp2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7309,7 +7074,6 @@ 'model_id': 'pkhw2vbphv4csrir', 'name': 'C30', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7318,8 +7082,8 @@ # name: test_device_registry[rl39uwgaqwjwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7340,7 +7104,6 @@ 'model_id': 'agwu93lr', 'name': 'Smart Odor Eliminator-Pro', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7349,8 +7112,8 @@ # name: test_device_registry[rojky4l6yyjreeilnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7371,7 +7134,6 @@ 'model_id': 'lieerjyy6l4ykjor', 'name': 'Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7380,8 +7142,8 @@ # name: test_device_registry[rsjdwgnbqky] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7402,7 +7164,6 @@ 'model_id': 'bngwdjsr', 'name': 'Télécommande lumières ZigBee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7411,8 +7172,8 @@ # name: test_device_registry[rvsneuipzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7433,7 +7194,6 @@ 'model_id': 'piuensvr', 'name': 'Signal repeater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7442,8 +7202,8 @@ # name: test_device_registry[rwp6kdezm97s2nktzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7464,7 +7224,6 @@ 'model_id': 'tkn2s79mzedk6pwr', 'name': 'Weihnachtsmann ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7473,8 +7232,8 @@ # name: test_device_registry[rzt2knqamsxjp8f9ycjjh] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7495,7 +7254,6 @@ 'model_id': '9f8pjxsmaqnk2tzr', 'name': 'MT15/MT29', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7504,8 +7262,8 @@ # name: test_device_registry[s3zzjdcfrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7526,7 +7284,6 @@ 'model_id': 'fcdjzz3s', 'name': 'Motion sensor lidl zigbee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7535,8 +7292,8 @@ # name: test_device_registry[s5ah3novtabe4tfdhb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7557,7 +7314,6 @@ 'model_id': 'dft4ebatvon3ha5s', 'name': 'Smart Kettle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7566,8 +7322,8 @@ # name: test_device_registry[sb3zdertrw50bgogkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7588,7 +7344,6 @@ 'model_id': 'gogb05wrtredz3bs', 'name': 'smart thermostats', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7597,8 +7352,8 @@ # name: test_device_registry[sdq2flqkq0lblcah2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7619,7 +7374,6 @@ 'model_id': 'haclbl0qkqlf2qds', 'name': 'Home Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7628,8 +7382,8 @@ # name: test_device_registry[shga3pmbkwhthvqxgklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7650,7 +7404,6 @@ 'model_id': 'xqvhthwkbmp3aghs', 'name': 'Pergola', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7659,8 +7412,8 @@ # name: test_device_registry[sifg4pfqsylsayg0jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7681,7 +7434,6 @@ 'model_id': '0gyaslysqfp4gfis', 'name': 'Study 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7690,8 +7442,8 @@ # name: test_device_registry[sj55nxhjftilowkejd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7712,7 +7464,6 @@ 'model_id': 'ekwolitfjhxn55js', 'name': 'ab6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7721,8 +7472,8 @@ # name: test_device_registry[slkkzcxa7yjqmetqlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7743,7 +7494,6 @@ 'model_id': 'qtemqjy7axczkkls', 'name': 'Dining 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7752,8 +7502,8 @@ # name: test_device_registry[snbu4b3vekhywztwqgcwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7774,7 +7524,6 @@ 'model_id': 'wtzwyhkev3b4ubns', 'name': 'House Water Level', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7783,8 +7532,8 @@ # name: test_device_registry[sq6fbd3pfkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7805,7 +7554,6 @@ 'model_id': 'p3dbf6qs', 'name': 'Anbau', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7814,8 +7562,8 @@ # name: test_device_registry[srbr1lpaydiq7l5sgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7836,7 +7584,6 @@ 'model_id': 's5l7qidyapl1rbrs', 'name': 'Ventus test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7845,8 +7592,8 @@ # name: test_device_registry[srp7cfjtn6sshwmt2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7867,7 +7614,6 @@ 'model_id': 'tmwhss6ntjfc7prs', 'name': 'Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7876,8 +7622,8 @@ # name: test_device_registry[svjjuwykgijjedurps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7898,7 +7644,6 @@ 'model_id': 'rudejjigkywujjvs', 'name': 'Bürocam', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7907,8 +7652,8 @@ # name: test_device_registry[sw1ejdomlmfubapizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7929,7 +7674,6 @@ 'model_id': 'ipabufmlmodje1ws', 'name': 'Värmelampa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7938,8 +7682,8 @@ # name: test_device_registry[swhtzki3qrz5ydchjboc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7960,7 +7704,6 @@ 'model_id': 'hcdy5zrq3ikzthws', 'name': 'Smogo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7969,8 +7712,8 @@ # name: test_device_registry[sxa4ealyi9cotiugzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7991,7 +7734,6 @@ 'model_id': 'guitoc9iylae4axs', 'name': 'HA Socket Delta Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8000,8 +7742,8 @@ # name: test_device_registry[syep74caderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8022,7 +7764,6 @@ 'model_id': '47peys', 'name': 'Ar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8031,8 +7772,8 @@ # name: test_device_registry[t5zosev6h6wmwyrajbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8053,7 +7794,6 @@ 'model_id': 'arywmw6h6vesoz5t', 'name': 'Rauchmelder Drucker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8062,8 +7802,8 @@ # name: test_device_registry[t7bvnnvplkwhdqm9qtn] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8084,7 +7824,6 @@ 'model_id': '9mqdhwklpvnnvb7t', 'name': 'Бризер Зал', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8093,8 +7832,8 @@ # name: test_device_registry[t88qaeyydamm9xhsddx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8115,7 +7854,6 @@ 'model_id': 'shx9mmadyyeaq88t', 'name': 'Plafond bureau ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8124,8 +7862,8 @@ # name: test_device_registry[tcdk0skzcpisexj2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8146,7 +7884,6 @@ 'model_id': '2jxesipczks0kdct', 'name': 'HVAC Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8155,8 +7892,8 @@ # name: test_device_registry[thdfxdqqlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8177,7 +7914,6 @@ 'model_id': 'qqdxfdht', 'name': 'bedroom blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8186,8 +7922,8 @@ # name: test_device_registry[trffx1ktlyu3tnmljd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8208,7 +7944,6 @@ 'model_id': 'lmnt3uyltk1xffrt', 'name': 'DirectietKamer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8217,8 +7952,8 @@ # name: test_device_registry[tskafaotnfigad6oqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8239,7 +7974,6 @@ 'model_id': 'o6dagifntoafakst', 'name': 'Sprinkler Cesare', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8248,8 +7982,8 @@ # name: test_device_registry[tvgoe1s3fabebcskjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8270,7 +8004,6 @@ 'model_id': 'kscbebaf3s1eogvt', 'name': 'WIFI Smoke alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8279,8 +8012,8 @@ # name: test_device_registry[u8h3bty7qgg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8301,7 +8034,6 @@ 'model_id': '7ytb3h8u', 'name': 'GIEX Watering Timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8310,8 +8042,8 @@ # name: test_device_registry[uBLyTOvlhoRWXKjrps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8332,7 +8064,6 @@ 'model_id': 'rjKXWRohlvOTyLBu', 'name': 'CAM PORCH', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8341,8 +8072,8 @@ # name: test_device_registry[uYmmlWz6zs0dIgYDjbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8363,7 +8094,6 @@ 'model_id': 'DYgId0sz6zWlmmYu', 'name': 'Siren', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8372,8 +8102,8 @@ # name: test_device_registry[uc9fL2NpR79iCzGIzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8394,7 +8124,6 @@ 'model_id': 'IGzCi97RpN2Lf9cu', 'name': 'N4-Auto', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8403,8 +8132,8 @@ # name: test_device_registry[uew54dymycjwz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8425,7 +8154,6 @@ 'model_id': 'myd45weu', 'name': 'Patates', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8434,8 +8162,8 @@ # name: test_device_registry[urm7i0rtdlabqiqygcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8456,7 +8184,6 @@ 'model_id': 'yqiqbaldtr0i7mru', 'name': 'WiFi Temperature & Humidity Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8465,8 +8192,8 @@ # name: test_device_registry[uvh6oeqrfliovfiwzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8487,7 +8214,6 @@ 'model_id': 'wifvoilfrqeo6hvu', 'name': 'Licht drucker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8496,8 +8222,8 @@ # name: test_device_registry[vADxMzNytofrgbm4zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8518,7 +8244,6 @@ 'model_id': '4mbgrfotyNzMxDAv', 'name': 'Air Purifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8527,8 +8252,8 @@ # name: test_device_registry[vayhq2aj3p3z6y2ggcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8549,7 +8274,6 @@ 'model_id': 'g2y6z3p3ja2qhyav', 'name': 'NP DownStairs North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8558,8 +8282,8 @@ # name: test_device_registry[vcrfgwvbuybgnj3zqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8580,7 +8304,6 @@ 'model_id': 'z3jngbyubvwgfrcv', 'name': 'Edesanya Energy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8589,8 +8312,8 @@ # name: test_device_registry[ve3ctzrqgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8611,7 +8334,6 @@ 'model_id': 'qrztc3ev', 'name': 'Temperature and humidity sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8620,8 +8342,8 @@ # name: test_device_registry[vnj3sa6mqahro6phjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8642,7 +8364,6 @@ 'model_id': 'hp6orhaqm6as3jnv', 'name': 'Master bedroom TV lights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8651,8 +8372,8 @@ # name: test_device_registry[vpfdskpi8pr8cbtfzjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8673,7 +8394,6 @@ 'model_id': 'ftbc8rp8ipksdfpv', 'name': 'mesa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8682,8 +8402,8 @@ # name: test_device_registry[vrhdtr5fawoiyth9qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8704,7 +8424,6 @@ 'model_id': '9htyiowaf5rtdhrv', 'name': 'Framboisiers', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8713,8 +8432,8 @@ # name: test_device_registry[vx2owjsg86g2ys93zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8735,7 +8454,6 @@ 'model_id': '39sy2g68gsjwo2xv', 'name': 'Ineox SP2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8744,8 +8462,8 @@ # name: test_device_registry[vzu7lkknqjz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8766,7 +8484,6 @@ 'model_id': 'nkkl7uzv', 'name': 'Zigby répéteur ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8775,8 +8492,8 @@ # name: test_device_registry[w8oht6v8aauqa0y8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8797,7 +8514,6 @@ 'model_id': '8y0aquaa8v6tho8w', 'name': 'dressoir spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8806,8 +8522,8 @@ # name: test_device_registry[w9hdtm88xj5crtc1qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8828,7 +8544,6 @@ 'model_id': '1ctrc5jx88mtdh9w', 'name': 'Puerta Casa ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8837,8 +8552,8 @@ # name: test_device_registry[wc6mumew8inrivi9zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8859,7 +8574,6 @@ 'model_id': '9ivirni8wemum6cw', 'name': 'Garáž čerpadlo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8868,8 +8582,8 @@ # name: test_device_registry[weozorgv28n2scribswh] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8890,7 +8604,6 @@ 'model_id': 'ircs2n82vgrozoew', 'name': 'InverFlow', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8899,8 +8612,8 @@ # name: test_device_registry[x4nogasbi8ggpb3lcd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8921,7 +8634,6 @@ 'model_id': 'l3bpgg8ibsagon4x', 'name': 'LSC Party String Light RGBIC+CCT ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8930,8 +8642,8 @@ # name: test_device_registry[x7quooqakw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8952,7 +8664,6 @@ 'model_id': 'aqoouq7x', 'name': 'Clima cucina', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8961,8 +8672,8 @@ # name: test_device_registry[xR2ASpOQgAAqu7Drlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8983,7 +8694,6 @@ 'model_id': 'rD7uqAAgQOpSA2Rx', 'name': 'Kit-Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8992,8 +8702,8 @@ # name: test_device_registry[xenxir4a0tn0p1qcqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9014,7 +8724,6 @@ 'model_id': 'cq1p0nt0a4rixnex', 'name': '4-433', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9023,8 +8732,8 @@ # name: test_device_registry[xihygtyd0d1faknkps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9045,7 +8754,6 @@ 'model_id': 'knkaf1d0dytgyhix', 'name': 'Security Camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9054,8 +8762,8 @@ # name: test_device_registry[xms6qowipdvjnkdgqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9076,7 +8784,6 @@ 'model_id': 'gdknjvdpiwoq6smx', 'name': 'Jardim frontal ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9085,8 +8792,8 @@ # name: test_device_registry[y1dkg3disbacmqfyjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9107,7 +8814,6 @@ 'model_id': 'yfqmcabsid3gkd1y', 'name': 'Shop Light 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9116,8 +8822,8 @@ # name: test_device_registry[y7eeatfzbtbyllk0qbnnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9138,7 +8844,6 @@ 'model_id': '0kllybtbzftaee7y', 'name': 'Soria', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9147,8 +8852,8 @@ # name: test_device_registry[ycttanlnpa0aivbfzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9169,7 +8874,6 @@ 'model_id': 'fbvia0apnlnattcy', 'name': 'AK1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9178,8 +8882,8 @@ # name: test_device_registry[yky6kunazmaitupzjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9200,7 +8904,6 @@ 'model_id': 'zputiamzanuk6yky', 'name': 'Floodlight', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9209,8 +8912,8 @@ # name: test_device_registry[yo2karkjuhzztxsfjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9231,7 +8934,6 @@ 'model_id': 'fsxtzzhujkrak2oy', 'name': 'Kalado Air Purifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9240,8 +8942,8 @@ # name: test_device_registry[yohkwjjdjlzludd3psm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9262,7 +8964,6 @@ 'model_id': '3ddulzljdjjwkhoy', 'name': 'Kattenbak', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9271,8 +8972,8 @@ # name: test_device_registry[yuanswy6scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9293,7 +8994,6 @@ 'model_id': '6ywsnauy', 'name': 'Fenêtre cuisine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9302,8 +9002,8 @@ # name: test_device_registry[yybgnzr3ztws] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9324,7 +9024,6 @@ 'model_id': '3rzngbyy', 'name': 'Grillhőmérő', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9333,8 +9032,8 @@ # name: test_device_registry[z7cu5t8bl9tt9fabjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9355,7 +9054,6 @@ 'model_id': 'baf9tt9lb8t5uc7z', 'name': 'Pokerlamp 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9364,8 +9062,8 @@ # name: test_device_registry[z8woiryqydmzonjdjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9386,7 +9084,6 @@ 'model_id': 'djnozmdyqyriow8z', 'name': 'Fakkel 8', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9395,8 +9092,8 @@ # name: test_device_registry[zaszonjgzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9417,7 +9114,6 @@ 'model_id': 'gjnozsaz', 'name': 'Raspy4 - Home Assistant', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9426,8 +9122,8 @@ # name: test_device_registry[zf8vgiwoa07jwegtjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9448,7 +9144,6 @@ 'model_id': 'tgewj70aowigv8fz', 'name': 'Stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9457,8 +9152,8 @@ # name: test_device_registry[zfHZQ7tZUBxAWjACjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9479,7 +9174,6 @@ 'model_id': 'CAjWAxBUZt7QZHfz', 'name': 'HL400', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9488,8 +9182,8 @@ # name: test_device_registry[zgiyrxflahjowpcckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9510,7 +9204,6 @@ 'model_id': 'ccpwojhalfxryigz', 'name': 'Boiler Temperature Controller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9519,8 +9212,8 @@ # name: test_device_registry[zjh9xhtm3gibs9kizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9541,7 +9234,6 @@ 'model_id': 'ik9sbig3mthx9hjz', 'name': 'Aubess Washing Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9550,8 +9242,8 @@ # name: test_device_registry[zoytcemodrn39zqwrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9572,7 +9264,6 @@ 'model_id': 'wqz93nrdomectyoz', 'name': 'PIR outside stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9581,8 +9272,8 @@ # name: test_device_registry[zrrraytdoanz33rlds] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9603,7 +9294,6 @@ 'model_id': 'lr33znaodtyarrrz', 'name': 'V20', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9612,8 +9302,8 @@ # name: test_device_registry[zspc4q1ut7swycnyzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9634,7 +9324,6 @@ 'model_id': 'yncyws7tu1q4cpsz', 'name': 'Wi-Fi hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9643,8 +9332,8 @@ # name: test_device_registry[zspxfhsvgn2hgtndzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9665,7 +9354,6 @@ 'model_id': 'dntgh2ngvshfxpsz', 'name': 'fakkel veranda ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9674,8 +9362,8 @@ # name: test_device_registry[zuqudhznfzttizpgbrnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9696,7 +9384,6 @@ 'model_id': 'gpzittzfnzhduquz', 'name': 'Inverter Pool Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9705,8 +9392,8 @@ # name: test_device_registry[zwnoax1om13nulplvtderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9727,7 +9414,6 @@ 'model_id': 'lplun31mo1xaonwz', 'name': 'TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9736,8 +9422,8 @@ # name: test_device_registry[zxmrfsffcearbajpjfx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9758,7 +9444,6 @@ 'model_id': 'pjabraecffsfrmxz', 'name': 'Register booster fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9767,8 +9452,8 @@ # name: test_device_registry[zyutbek7wdm1b4cgzckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9789,7 +9474,6 @@ 'model_id': 'gc4b1mdw7kebtuyz', 'name': 'pid_relay_2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9798,8 +9482,8 @@ # name: test_device_registry[zzz87dkfce6pdqxwtk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9820,7 +9504,6 @@ 'model_id': 'wxqdp6ecfkd78zzz', 'name': 'Mini-Split', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 8365f6a79156..b3df44bdac2d 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -84,8 +84,8 @@ # name: test_waste_pickup_calendar.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -106,7 +106,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 8de837cab936..3fbe5efd26c7 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -53,8 +53,8 @@ # name: test_sensors[sensor.twente_milieu_christmas_tree_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -135,8 +134,8 @@ # name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -217,8 +215,8 @@ # name: test_sensors[sensor.twente_milieu_organic_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -239,7 +237,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -299,8 +296,8 @@ # name: test_sensors[sensor.twente_milieu_packages_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -321,7 +318,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -381,8 +377,8 @@ # name: test_sensors[sensor.twente_milieu_paper_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -403,7 +399,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/unifiprotect/snapshots/test_init.ambr b/tests/components/unifiprotect/snapshots/test_init.ambr index e53b25c4ad34..3a3e7558a835 100644 --- a/tests/components/unifiprotect/snapshots/test_init.ambr +++ b/tests/components/unifiprotect/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup_creates_nvr_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'UNVR-PRO', 'name': 'UnifiProtect', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '6.0.0', 'via_device_id': None, diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 0ac1ec007274..c5b237682700 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -52,8 +52,8 @@ # name: test_uptime_sensor.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -68,7 +68,6 @@ 'model_id': None, 'name': 'Uptime', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 0383abc0313b..96165c662ff8 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': '99', 'name': 'Bedroom kid 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': '8', 'name': 'Input', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': '123', 'name': 'Kitchen (VMB2BLE)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '2.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -112,15 +109,14 @@ 'model_id': '9', 'name': 'Dimmer full name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6g7', 'sw_version': '1.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -141,15 +137,14 @@ 'model_id': '10', 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12345', 'sw_version': '1.0.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -170,15 +165,14 @@ 'model_id': '4', 'name': 'Input', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -199,15 +193,14 @@ 'model_id': '1', 'name': 'Living room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'asdfghjk', 'sw_version': '3.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -228,15 +221,14 @@ 'model_id': '3', 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'qwerty1234567', 'sw_version': '1.1.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -257,15 +249,14 @@ 'model_id': '2', 'name': 'Living room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'qwerty123', 'sw_version': '1.0.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -286,7 +277,6 @@ 'model_id': '10', 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '1.0.1', 'via_device_id': , diff --git a/tests/components/vesync/snapshots/test_binary_sensor.ambr b/tests/components/vesync/snapshots/test_binary_sensor.ambr index be23b3698d64..872d2b3536c1 100644 --- a/tests/components/vesync/snapshots/test_binary_sensor.ambr +++ b/tests/components/vesync/snapshots/test_binary_sensor.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -262,8 +255,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -284,7 +277,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -327,8 +319,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -349,7 +341,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -462,8 +453,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -484,7 +475,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -597,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -619,7 +609,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -704,8 +693,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -726,7 +715,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -741,8 +729,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -763,7 +751,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -778,8 +765,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -800,7 +787,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -815,8 +801,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -837,7 +823,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -852,8 +837,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -874,7 +859,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 75a1ac7b7f9d..c3b59abbe3de 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -104,8 +103,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -126,7 +125,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -205,8 +203,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -227,7 +225,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -308,8 +305,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -330,7 +327,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -411,8 +407,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -433,7 +429,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -448,8 +443,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -470,7 +465,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -485,8 +479,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -507,7 +501,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -522,8 +515,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -544,7 +537,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -559,8 +551,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -581,7 +573,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -596,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -618,7 +609,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -633,8 +623,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -655,7 +645,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -670,8 +659,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -692,7 +681,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -707,8 +695,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -729,7 +717,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -813,8 +800,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -835,7 +822,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -850,8 +836,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -872,7 +858,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -887,8 +872,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -909,7 +894,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_humidifier.ambr b/tests/components/vesync/snapshots/test_humidifier.ambr index 6af8093874bd..c258a9e8538d 100644 --- a/tests/components/vesync/snapshots/test_humidifier.ambr +++ b/tests/components/vesync/snapshots/test_humidifier.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -262,8 +255,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -284,7 +277,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -299,8 +291,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -321,7 +313,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -401,8 +392,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -423,7 +414,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -507,8 +497,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -529,7 +519,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -611,8 +600,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -633,7 +622,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -648,8 +636,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -670,7 +658,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -685,8 +672,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -707,7 +694,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -722,8 +708,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -744,7 +730,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -759,8 +744,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -781,7 +766,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index a3ac56d74ad8..bc15410d43ce 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -320,8 +313,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -342,7 +335,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -415,8 +407,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -437,7 +429,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -452,8 +443,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -474,7 +465,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -489,8 +479,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -511,7 +501,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -526,8 +515,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -548,7 +537,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -563,8 +551,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -585,7 +573,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -600,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -622,7 +609,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -713,8 +699,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -735,7 +721,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -750,8 +735,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -772,7 +757,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index d5d334e80f66..b7a4971431b9 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -140,8 +139,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -162,7 +161,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -229,8 +227,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -251,7 +249,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -419,8 +416,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -441,7 +438,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -609,8 +605,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -631,7 +627,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -934,8 +929,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -956,7 +951,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1251,8 +1245,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1273,7 +1267,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1288,8 +1281,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1310,7 +1303,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1325,8 +1317,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1347,7 +1339,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1415,8 +1406,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1437,7 +1428,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1613,8 +1603,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1635,7 +1625,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1703,8 +1692,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1725,7 +1714,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2076,8 +2064,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2098,7 +2086,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2113,8 +2100,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2135,7 +2122,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2150,8 +2136,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2172,7 +2158,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2187,8 +2172,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2209,7 +2194,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index a1458f6a8a36..b3f0eba8c265 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -88,8 +87,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -110,7 +109,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -221,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -243,7 +241,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -354,8 +351,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -376,7 +373,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -487,8 +483,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -509,7 +505,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -524,8 +519,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -546,7 +541,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -561,8 +555,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -583,7 +577,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -598,8 +591,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -620,7 +613,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -635,8 +627,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -657,7 +649,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -768,8 +759,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -790,7 +781,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -997,8 +987,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1019,7 +1009,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1130,8 +1119,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1152,7 +1141,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1216,8 +1204,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1238,7 +1226,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1301,8 +1288,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1323,7 +1310,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1338,8 +1324,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1360,7 +1346,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1424,8 +1409,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1446,7 +1431,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_update.ambr b/tests/components/vesync/snapshots/test_update.ambr index e45b489754aa..04d7ff10d744 100644 --- a/tests/components/vesync/snapshots/test_update.ambr +++ b/tests/components/vesync/snapshots/test_update.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -101,8 +100,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -123,7 +122,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -199,8 +197,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -221,7 +219,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -297,8 +294,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -319,7 +316,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -395,8 +391,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -417,7 +413,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -493,8 +488,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -515,7 +510,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +585,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +607,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -689,8 +682,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -711,7 +704,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -787,8 +779,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -809,7 +801,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -885,8 +876,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -907,7 +898,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -983,8 +973,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1005,7 +995,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1081,8 +1070,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1103,7 +1092,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1179,8 +1167,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1201,7 +1189,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1277,8 +1264,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1299,7 +1286,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1375,8 +1361,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1397,7 +1383,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1474,8 +1459,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1496,7 +1481,6 @@ 'model_id': None, 'name': 'CoreBreeze 432S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vilfo/snapshots/test_init.ambr b/tests/components/vilfo/snapshots/test_init.ambr index 1c33ab98a2c1..500139c8de47 100644 --- a/tests/components/vilfo/snapshots/test_init.ambr +++ b/tests/components/vilfo/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry[with_mac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': None, 'name': 'Vilfo Router', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1.0', 'via_device_id': None, @@ -38,8 +37,8 @@ # name: test_device_registry[without_mac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -61,7 +60,6 @@ 'model_id': None, 'name': 'Vilfo Router', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1.0', 'via_device_id': None, diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 75a97e2fd54b..b1b172d23bbb 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -38,8 +38,8 @@ # name: test_entity_attributes.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -60,7 +60,6 @@ 'model_id': None, 'name': 'LG webOS TV MODEL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'major.minor', 'via_device_id': None, diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 885a32234dd1..1d24b827924e 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -52,8 +52,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_admin].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -74,7 +74,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -134,8 +133,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_created].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -156,7 +155,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -220,8 +218,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_days_until_expiration].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -242,7 +240,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -302,8 +299,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_expires].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -324,7 +321,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -384,8 +380,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_last_updated].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -406,7 +402,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -465,8 +460,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_owner].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -487,7 +482,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -546,8 +540,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_registrant].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -568,7 +562,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -627,8 +620,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_registrar].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -649,7 +642,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -708,8 +700,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_reseller].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -730,7 +722,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -841,8 +832,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_status].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -863,7 +854,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index 31c239876803..21d04fd18ba4 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[12345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'henk', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Body+', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index a833087b8082..043044fa868e 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -2,8 +2,8 @@ # name: test_device_snapshot DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 51225d300839..a9b44bc1cf41 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -61,8 +61,8 @@ # name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -87,7 +87,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, @@ -155,8 +154,8 @@ # name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -181,7 +180,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 26bf8feed48d..9f47e6213ec3 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -2,8 +2,8 @@ # name: test_cover_device[config_prod_awning_dimmer.json-status_prod_awning.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr index 34b55c0ac3e1..b62cd611dc5a 100644 --- a/tests/components/wmspro/snapshots/test_init.ambr +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-19239] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '19239', 'sw_version': None, 'via_device_id': , @@ -33,8 +32,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-58717] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , @@ -64,8 +62,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-97358] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-19239] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '19239', 'sw_version': None, 'via_device_id': , @@ -126,8 +122,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-58717] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , @@ -157,8 +152,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-97358] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , @@ -188,8 +182,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-116682] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': None, 'name': 'Wohnzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '116682', 'sw_version': None, 'via_device_id': , @@ -219,8 +212,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-172555] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': None, 'name': 'Badezimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '172555', 'sw_version': None, 'via_device_id': , @@ -250,8 +242,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-18894] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': None, 'name': 'Wohnebene alle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '18894', 'sw_version': None, 'via_device_id': , @@ -281,8 +272,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-230952] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': None, 'name': 'Sportzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '230952', 'sw_version': None, 'via_device_id': , @@ -312,8 +302,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-284942] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '284942', 'sw_version': None, 'via_device_id': , @@ -343,8 +332,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-328518] DeviceRegistryEntrySnapshot({ 'area_id': 'alle', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': None, 'name': 'alle Rollläden', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '328518', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index 3fb4ac620efe..aa556fd9c42a 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -2,8 +2,8 @@ # name: test_light_device[config_prod_awning_dimmer.json-status_prod_dimmer.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index 95dfd47a905d..87b1e4fe59e1 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -16,8 +16,8 @@ # name: test_scene_room_device[config_test.json] DeviceRegistryEntrySnapshot({ 'area_id': 'raum_0', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -38,7 +38,6 @@ 'model_id': None, 'name': 'Raum 0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '42581', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_switch.ambr b/tests/components/wmspro/snapshots/test_switch.ambr index b5fcc25c5ea3..144ec10ea724 100644 --- a/tests/components/wmspro/snapshots/test_switch.ambr +++ b/tests/components/wmspro/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_switch_device[config_prod_load_switch.json-status_prod_load_switch.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'HEIZUNG LINKS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '499120', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index 88ce626f4aa2..994672355869 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.wolf-smartset.com/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'test-device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/xthings_cloud/snapshots/test_init.ambr b/tests/components/xthings_cloud/snapshots/test_init.ambr index d0c552b66cd4..c1e7dda19e1f 100644 --- a/tests/components/xthings_cloud/snapshots/test_init.ambr +++ b/tests/components/xthings_cloud/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[XT-LT050] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Porch Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[XT-LT100] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Hallway Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[XT-LT200] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Bedroom Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.1', 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_devices[XT-PL50] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Smart Plug 50', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_devices[XT-PL100] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Smart Plug 100', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_devices[XT-LK50] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Front Door Lock', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index 226d0bdbba91..5d013ac7f213 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -2,8 +2,8 @@ # name: test_doorbell_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'tmt100 Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index 3f89fe085253..f0c282c5eb1e 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'online_with_doorsense Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, diff --git a/tests/components/zinvolt/snapshots/test_init.ambr b/tests/components/zinvolt/snapshots/test_init.ambr index 657cb27c219b..5e1c9b893105 100644 --- a/tests/components/zinvolt/snapshots/test_init.ambr +++ b/tests/components/zinvolt/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device[BAT002] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'ZVS4000', 'name': 'Battery - 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'BAT002', 'sw_version': 'V1.20', 'via_device_id': , @@ -33,8 +32,8 @@ # name: test_device[ZVG011025120088] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'ZVS4000', 'name': 'Zinvolt Batterij', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ZVG011025120088', 'sw_version': 'V1.20', 'via_device_id': None, diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index 2da81a956021..d35a0affa7f0 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -2,8 +2,8 @@ # name: test_device_info_called DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'test-name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'test-sw', 'via_device_id': , @@ -37,8 +36,8 @@ # name: test_device_info_called.1 DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'test-name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'test-sw', 'via_device_id': , diff --git a/tests/syrupy.py b/tests/syrupy.py index 09d5ea353f9c..aa1b5446e942 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -177,14 +177,8 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - # Remove single config entry and subentry ids to not break snapshots - serialized.pop("config_entry_id") - serialized.pop("config_subentry_id") - - # Set removed composite device attributes to ANY to not break snapshots - serialized["config_entries"] = ANY - serialized["config_entries_subentries"] = ANY - serialized["primary_config_entry"] = ANY + serialized["config_entry_id"] = ANY + serialized["config_subentry_id"] = ANY return cls._remove_created_and_modified_at(serialized) From 377615b243e5aefd732293213d8395e410c0b179 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:19:14 +0200 Subject: [PATCH 670/707] Use modern device registry API for device move in anthropic (#176656) --- .../components/anthropic/__init__.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 3d7c3ce41386..05f30d960b90 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER from .coordinator import AnthropicConfigEntry, AnthropicCoordinator @@ -137,7 +137,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -147,20 +147,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) From 6eab2d784ff0e6329d4d7dc4aa43e6b365c1e3cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:19:26 +0200 Subject: [PATCH 671/707] Use modern device registry API for device move in github (#176657) --- homeassistant/components/github/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index 4d2dc968eb9c..4b4bb22d6d9c 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -98,9 +98,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> if device := dev_reg.async_get_device({(DOMAIN, repository)}): dev_reg.async_update_device( device.id, - remove_config_entry_id=entry.entry_id, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=entry.entry_id, + new_config_entry_id=entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) hass.config_entries.async_update_entry(entry, minor_version=2) return True From c2d41b31994fde75761b8afd4fda2400f35b2f03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:19:36 +0200 Subject: [PATCH 672/707] Use modern device registry API for device move in google_generative_ai_conversation (#176658) --- .../__init__.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index b3f0eb0ce829..2667a278c9c1 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( DEFAULT_AI_TASK_NAME, @@ -182,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -192,20 +192,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) From de5c7b1b3cca646529c9c3f7c4feeaf5c8f00e7b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:19:52 +0200 Subject: [PATCH 673/707] Use modern device registry API for device move in lifx (#176659) --- homeassistant/components/lifx/migration.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py index 60aded43fb90..e84b2b465d3c 100644 --- a/homeassistant/components/lifx/migration.py +++ b/homeassistant/components/lifx/migration.py @@ -61,8 +61,7 @@ def async_migrate_entities_devices( migrated_devices.append(dev_entry.id) device_registry.async_update_device( dev_entry.id, - add_config_entry_id=new_entry.entry_id, - remove_config_entry_id=legacy_entry_id, + new_config_entry_id=new_entry.entry_id, ) entity_registry = er.async_get(hass) From edfc4537fd0dac735a5b4ec418efaa8b613ddb65 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:20:06 +0200 Subject: [PATCH 674/707] Use modern device registry API for device move in ollama (#176661) --- homeassistant/components/ollama/__init__.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 16717b66d4d2..7ee31e4e8662 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.util.ssl import get_default_context from .const import ( @@ -192,7 +192,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -202,20 +202,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) From 18492eb6607432e6e4f2ab16b7f895d69265a903 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:22:33 +0200 Subject: [PATCH 675/707] Use modern device registry API for device move in openai_conversation (#176662) --- .../openai_conversation/__init__.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index f34f88c2cae7..77ccb98d9a35 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -36,7 +36,7 @@ from homeassistant.helpers import ( selector, ) from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( CONF_CHAT_MODEL, @@ -386,7 +386,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # Device and entity registries will set the disabled_by flag to None # when moving a device or entity disabled by CONFIG_ENTRY to an enabled # config entry, but we want to set it to USER instead, - device_disabled_by = device.disabled_by + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -396,20 +396,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) From cdf389fc9a90f7dd31d118a031bc6e9a27e7414b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:22:43 +0200 Subject: [PATCH 676/707] Use modern device registry API for device move in scrape (#176663) --- homeassistant/components/scrape/__init__.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 95177b7d2b46..1fb19d2663ca 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -236,20 +236,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> ) device_reg.async_update_device( device.id, - add_config_entry_id=entry.entry_id, - add_config_subentry_id=subentry_id, + new_config_entry_id=entry.entry_id, + new_config_subentry_id=subentry_id, new_identifiers=new_identifiers, ) - # Removing None from the list of subentries if existing - # as the device should only belong to the subentry - # and not the main config entry - device_reg.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) - # Update the resource config new_config_entry_data = dict(entry.options) new_config_entry_data[CONF_AUTH] = {} From b195a8e4de3a638481b87c52f28fd8d33387555c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:23:10 +0200 Subject: [PATCH 677/707] Use modern device registry API for device move in waqi (#176664) --- homeassistant/components/waqi/__init__.py | 25 +++++++---------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index bf191e5b6c6f..41b60a6bd823 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator @@ -126,10 +126,10 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if device is not None: - # Device and entity registries don't update the disabled_by flag when - # moving a device or entity from one config entry to another, so we - # need to do it manually. - device_disabled_by = device.disabled_by + # The device registry will set the disabled_by flag to None when + # moving a device disabled by CONFIG_ENTRY to an enabled config + # entry, but we want to set it to USER instead. + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -138,20 +138,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device_registry.async_update_device( device.id, disabled_by=device_disabled_by, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if parent_entry.entry_id != entry.entry_id: await hass.config_entries.async_remove(entry.entry_id) From 0a758eec4ec08b6ebeb060a656b3e61757d9cd46 Mon Sep 17 00:00:00 2001 From: Hamish Date: Fri, 17 Jul 2026 19:01:36 +0930 Subject: [PATCH 678/707] Add reconfigure flow to Gatus (#176646) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Erwin Douna --- homeassistant/components/gatus/config_flow.py | 82 +++++++---- .../components/gatus/quality_scale.yaml | 2 +- homeassistant/components/gatus/strings.json | 13 +- tests/components/gatus/test_config_flow.py | 131 +++++++++++++----- 4 files changed, 166 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py index 972f200abae7..8abba8d95641 100644 --- a/homeassistant/components/gatus/config_flow.py +++ b/homeassistant/components/gatus/config_flow.py @@ -46,33 +46,25 @@ class GatusConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: + user_input[CONF_URL] = str( + URL(user_input[CONF_URL]) + .with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: - url = URL(user_input[CONF_URL]) - except ValueError: - errors["base"] = "invalid_url" + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus setup") + errors["base"] = "unknown" else: - if url.scheme not in {"http", "https"} or not url.host: - errors["base"] = "invalid_url" - else: - normalized_url = str( - url.with_query(None) - .with_fragment(None) - .with_user(None) - .with_password(None) - ).rstrip("/") - user_input[CONF_URL] = normalized_url - - self._async_abort_entries_match({CONF_URL: normalized_url}) - - try: - await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception during Gatus setup") - errors["base"] = "unknown" - else: - return self.async_create_entry(title="Gatus", data=user_input) + return self.async_create_entry(title="Gatus", data=user_input) return self.async_show_form( step_id="user", @@ -82,6 +74,46 @@ class GatusConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + user_input[CONF_URL] = str( + url.with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + if user_input[CONF_URL] != reconfigure_entry.data[CONF_URL]: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Gatus reconfigure") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data + ), + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect to the server.""" diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index 3d9207ece6b9..dab6799b0a88 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -76,7 +76,7 @@ rules: icon-translations: status: exempt comment: Entities use the connectivity device class for their icon and define no custom icons. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration does not require user intervention repairs. diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json index 6f6610ddbb01..413dc7180c91 100644 --- a/homeassistant/components/gatus/strings.json +++ b/homeassistant/components/gatus/strings.json @@ -1,14 +1,23 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_url": "Please enter a valid absolute URL (e.g., http://192.168.1.50:8080)", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::gatus::config::step::user::data_description::url%]" + }, + "description": "[%key:component::gatus::config::step::user::description%]" + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]" diff --git a/tests/components/gatus/test_config_flow.py b/tests/components/gatus/test_config_flow.py index 45fbc09afe8c..45d9bf8c1241 100644 --- a/tests/components/gatus/test_config_flow.py +++ b/tests/components/gatus/test_config_flow.py @@ -61,40 +61,6 @@ async def test_form_success_with_path( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_url( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_gatus_client: AsyncMock -) -> None: - """Test handling of a malformed URL and subsequent recovery.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "gatus.example.com"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_url"} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "http://gatus.example.com:abc"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_url"} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "http://gatus.example.com:8080"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(mock_setup_entry.mock_calls) == 1 - - @pytest.mark.parametrize( ("side_effect", "error_key"), [ @@ -152,3 +118,100 @@ async def test_form_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test reconfigure flow errors and recover.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts if the new URL is already configured.""" + mock_config_entry.add_to_hass(hass) + + other_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example3.com:8080"}, + entry_id="other_id", + ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example3.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From a80f068d748d7e2724292ebad662a944fe6f4b76 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 11:46:05 +0200 Subject: [PATCH 679/707] Use modern device registry API to remove devices (part 4) (#176673) --- .../components/tankerkoenig/coordinator.py | 4 +--- homeassistant/components/teslemetry/__init__.py | 5 +---- homeassistant/components/tplink_omada/__init__.py | 4 +--- homeassistant/components/tuya/__init__.py | 4 +--- .../components/unifi_access/coordinator.py | 5 +---- homeassistant/components/unifiprotect/migrate.py | 6 +----- homeassistant/components/velbus/__init__.py | 14 +++++--------- homeassistant/components/vistapool/__init__.py | 4 +--- homeassistant/components/xbox/coordinator.py | 4 +--- homeassistant/components/yolink/__init__.py | 4 +--- homeassistant/components/yoto/coordinator.py | 4 +--- homeassistant/components/youtube/__init__.py | 4 +--- 12 files changed, 16 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index c8dd1b396dad..1e47c5344a60 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -108,9 +108,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf for station_id in self._selected_stations ): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) if len(self.stations) > 10: _LOGGER.warning( diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index f9815ff9f46d..7ae42ab703ea 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -508,10 +508,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - identifier in current_devices for identifier in device_entry.identifiers ): LOGGER.debug("Removing stale device %s", device_entry.id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) entry.runtime_data = TeslemetryData( vehicles=vehicles, diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 559f9eab1851..a782ae004538 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -98,9 +98,7 @@ def _remove_old_devices( (i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None ) if mac and mac not in omada_devices: - device_registry.async_update_device( - registered_device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(registered_device.id) async def async_migrate_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 48a7e5212f00..1dc7709a84a1 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -78,9 +78,7 @@ async def cleanup_device_registry( ): for item in device_entry.identifiers: if item[0] == DOMAIN and item[1] not in device_manager.device_map: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) break diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index adb67b35d27b..a17989b44871 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -297,10 +297,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): for identifier in device.identifiers ): continue - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) def _on_ws_connect(self) -> None: """Handle WebSocket connection established.""" diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 8ed230acdf89..d2a94eb53b38 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -148,11 +148,7 @@ def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> N for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): if device.model_id != _AIPORT_DEVICE_TYPE: continue - # Detaching the config entry removes the device (it has no other entry) - # and its entities along with it. - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) @callback diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 4e8f0f0bfc4f..5cc742925995 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -138,22 +138,18 @@ async def async_remove_config_entry_device( config_entry: VelbusConfigEntry, device_entry: dr.DeviceEntry, ) -> bool: - """Allow removing a Velbus device and detach its sub-devices. + """Allow removing a Velbus device and its sub-devices. - Sub-devices are detached from this config entry when their parent is - removed. If the device is still on the bus, it may be recreated when - the integration is reloaded or started again. + Sub-devices are removed along with their parent. If the device is still + on the bus, it may be recreated when the integration is reloaded or + started again. """ if config_entry.entry_id not in device_entry.config_entries: return False dev_reg = dr.async_get(hass) for sub_device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id): if sub_device.via_device_id == device_entry.id: - dev_reg.async_update_device( - sub_device.id, - remove_config_entry_id=config_entry.entry_id, - via_device_id=None, - ) + dev_reg.async_remove_device(sub_device.id) return True diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 1f34877ab995..d7a01cbd6578 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -150,9 +150,7 @@ def _async_remove_stale_devices( for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): pool_id = next((i[1] for i in device.identifiers if i[0] == DOMAIN), None) if pool_id is not None and pool_id not in valid_pool_ids: - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) async def _async_initial_refresh( diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 368157a22c2b..d9b0766d70eb 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -127,9 +127,7 @@ class XboxConsolesCoordinator(XboxBaseCoordinator[dict[str, SmartglassConsole]]) and not set(device.identifiers) & identifiers ): _LOGGER.debug("Removing stale device %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return {console.id: console for console in consoles.result} diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index a1917c847870..c2404ea1419c 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -169,9 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: YoLinkConfigEntry) -> bo identifier[0] == DOMAIN and device_coordinators.get(identifier[1]) is None ): - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/yoto/coordinator.py b/homeassistant/components/yoto/coordinator.py index 026b00eb11ed..b3d94b1bb4af 100644 --- a/homeassistant/components/yoto/coordinator.py +++ b/homeassistant/components/yoto/coordinator.py @@ -161,9 +161,7 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]): (ident[1] for ident in device.identifiers if ident[0] == DOMAIN), None ) if player_id is not None and player_id not in self.client.players: - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def _async_load_library(self) -> None: """Load the card library and groups; failures only affect browsing.""" diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index dff9652398d7..b6f12c7efc9c 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -71,6 +71,4 @@ async def delete_devices( dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for dev_entry in dev_entries: if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers): - device_registry.async_update_device( - dev_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(dev_entry.id) From d1b8881603785867f3e7659177aacfe871a72598 Mon Sep 17 00:00:00 2001 From: Arnaud Launay <2205303+alaunay@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:01:04 +0200 Subject: [PATCH 680/707] =?UTF-8?q?change=20suez=20price=20to=20price=20/?= =?UTF-8?q?=20m=C2=B3=20(#176611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/suez_water/sensor.py | 5 +++-- .../components/suez_water/snapshots/test_sensor.ambr | 12 +++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 262457c4620e..d7e67ba34fbf 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant @@ -41,8 +42,8 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( SuezWaterSensorEntityDescription( key="water_price", translation_key="water_price", - native_unit_of_measurement=CURRENCY_EURO, - device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda suez_data: suez_data.price, ), ) diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index 7cedf7dc476d..53e3394f83a7 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -24,7 +26,7 @@ 'object_id_base': 'Water price', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Water price', 'platform': 'suez_water', @@ -33,16 +35,16 @@ 'supported_features': 0, 'translation_key': 'water_price', 'unique_id': '123456_water_price', - 'unit_of_measurement': '€', + 'unit_of_measurement': '€/m³', }) # --- # name: test_sensors_valid_state[sensor.suez_mock_device_water_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by toutsurmoneau.fr', - : 'monetary', : 'Suez mock device Water price', - : '€', + : , + : '€/m³', }), 'context': , 'entity_id': 'sensor.suez_mock_device_water_price', From 40e243a1765608b99277a659d53eee0d94cbecac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Alves?= <32654466+luismalves@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:01:53 +0100 Subject: [PATCH 681/707] Add LiteLLM integration (#172960) Co-authored-by: Claude Opus 4.8 (1M context) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/litellm/__init__.py | 33 ++ .../components/litellm/config_flow.py | 253 +++++++++++ homeassistant/components/litellm/const.py | 18 + .../components/litellm/conversation.py | 69 +++ .../components/litellm/coordinator.py | 74 ++++ homeassistant/components/litellm/entity.py | 211 ++++++++++ .../components/litellm/manifest.json | 13 + .../components/litellm/quality_scale.yaml | 98 +++++ homeassistant/components/litellm/strings.json | 55 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + tests/components/litellm/__init__.py | 25 ++ tests/components/litellm/conftest.py | 133 ++++++ .../litellm/snapshots/test_conversation.ambr | 295 +++++++++++++ tests/components/litellm/test_config_flow.py | 397 ++++++++++++++++++ tests/components/litellm/test_conversation.py | 297 +++++++++++++ tests/components/litellm/test_init.py | 61 +++ 21 files changed, 2053 insertions(+) create mode 100644 homeassistant/components/litellm/__init__.py create mode 100644 homeassistant/components/litellm/config_flow.py create mode 100644 homeassistant/components/litellm/const.py create mode 100644 homeassistant/components/litellm/conversation.py create mode 100644 homeassistant/components/litellm/coordinator.py create mode 100644 homeassistant/components/litellm/entity.py create mode 100644 homeassistant/components/litellm/manifest.json create mode 100644 homeassistant/components/litellm/quality_scale.yaml create mode 100644 homeassistant/components/litellm/strings.json create mode 100644 tests/components/litellm/__init__.py create mode 100644 tests/components/litellm/conftest.py create mode 100644 tests/components/litellm/snapshots/test_conversation.ambr create mode 100644 tests/components/litellm/test_config_flow.py create mode 100644 tests/components/litellm/test_conversation.py create mode 100644 tests/components/litellm/test_init.py diff --git a/.strict-typing b/.strict-typing index 8f1239d45665..e7e3bd1c8870 100644 --- a/.strict-typing +++ b/.strict-typing @@ -350,6 +350,7 @@ homeassistant.components.lifx.* homeassistant.components.light.* homeassistant.components.linkplay.* homeassistant.components.litejet.* +homeassistant.components.litellm.* homeassistant.components.litterrobot.* homeassistant.components.llama_cpp.* homeassistant.components.local_ip.* diff --git a/CODEOWNERS b/CODEOWNERS index 8ce2921396d0..bfa4aad156b6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1034,6 +1034,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar +/homeassistant/components/litellm/ @luismalves +/tests/components/litellm/ @luismalves /homeassistant/components/litterrobot/ @natekspencer @tkdrob /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet diff --git a/homeassistant/components/litellm/__init__.py b/homeassistant/components/litellm/__init__.py new file mode 100644 index 000000000000..5447eeb01454 --- /dev/null +++ b/homeassistant/components/litellm/__init__.py @@ -0,0 +1,33 @@ +"""The LiteLLM integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +PLATFORMS = [Platform.CONVERSATION] + + +async def async_setup_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Set up LiteLLM from a config entry.""" + coordinator = LiteLLMDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: LiteLLMConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Unload LiteLLM.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py new file mode 100644 index 000000000000..0b8df8be1d44 --- /dev/null +++ b/homeassistant/components/litellm/config_flow.py @@ -0,0 +1,253 @@ +"""Config flow for LiteLLM integration.""" + +import logging +from typing import Any, override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .const import ( + CONF_PROMPT, + DOMAIN, + PLACEHOLDER_API_KEY, + RECOMMENDED_CONVERSATION_OPTIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the proxy.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate the API key is invalid.""" + + +def _normalize_url(url: str) -> str: + """Normalize the proxy URL, ensuring it ends with the OpenAI `/v1` path.""" + parsed = URL(url.strip()) + path = parsed.path.rstrip("/") + if not path.endswith("/v1"): + path = f"{path}/v1" + return str(parsed.with_path(path)) + + +async def _get_models(hass: HomeAssistant, url: str, api_key: str | None) -> list[str]: + """Fetch the available model names from the LiteLLM proxy. + + Uses the OpenAI-compatible `/v1/models` endpoint, which a LiteLLM proxy + serves with the configured model names. + """ + client = AsyncOpenAI( + base_url=url, + api_key=api_key or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + try: + return [ + model.id async for model in client.with_options(timeout=10.0).models.list() + ] + except (AuthenticationError, PermissionDeniedError) as err: + raise InvalidAuth from err + except OpenAIError as err: + raise CannotConnect from err + + +class LiteLLMConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LiteLLM.""" + + VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + url = _normalize_url(user_input[CONF_URL]) + api_key = user_input.get(CONF_API_KEY) + self._async_abort_entries_match({CONF_URL: url}) + try: + await _get_models(self.hass, url, api_key) + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + data = {CONF_URL: url} + if api_key: + data[CONF_API_KEY] = api_key + return self.async_create_entry( + title=URL(url).host or url, + data=data, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Optional(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class LiteLLMSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for LiteLLM.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.models: list[str] = [] + + async def _fetch_models(self) -> None: + """Fetch models from the LiteLLM proxy.""" + entry = self._get_entry() + self.models = await _get_models( + self.hass, entry.data[CONF_URL], entry.data.get(CONF_API_KEY) + ) + + +class ConversationFlowHandler(LiteLLMSubentryFlowHandler): + """Handle conversation subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self.options: dict[str, Any] = {} + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == SOURCE_USER + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a conversation agent.""" + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + return await self.async_step_init(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a conversation agent.""" + self.options = self._get_reconfigure_subentry().data.copy() + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage conversation agent configuration.""" + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if self._is_new: + return self.async_create_entry( + title=user_input[CONF_MODEL], data=user_input + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + title=user_input[CONF_MODEL], + data=user_input, + ) + + try: + await self._fetch_models() + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + options = [SelectOptionDict(value=model, label=model) for model in self.models] + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + + if suggested_llm_apis := self.options.get(CONF_LLM_HASS_API): + valid_api_ids = {api["value"] for api in hass_apis} + self.options[CONF_LLM_HASS_API] = [ + api for api in suggested_llm_apis if api in valid_api_ids + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_MODEL, default=self.options.get(CONF_MODEL) + ): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": self.options.get( + CONF_PROMPT, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT], + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=self.options.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) diff --git a/homeassistant/components/litellm/const.py b/homeassistant/components/litellm/const.py new file mode 100644 index 000000000000..8f645e234519 --- /dev/null +++ b/homeassistant/components/litellm/const.py @@ -0,0 +1,18 @@ +"""Constants for the LiteLLM integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.helpers import llm + +DOMAIN = "litellm" +LOGGER = logging.getLogger(__package__) + +# LiteLLM proxies may run without authentication. The OpenAI client requires a +# non-empty API key, so we send a placeholder when the user did not provide one. +PLACEHOLDER_API_KEY = "sk-no-key-required" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/litellm/conversation.py b/homeassistant/components/litellm/conversation.py new file mode 100644 index 000000000000..c6d979aba8dd --- /dev/null +++ b/homeassistant/components/litellm/conversation.py @@ -0,0 +1,69 @@ +"""Conversation support for LiteLLM.""" + +from typing import Literal, override + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LiteLLMConfigEntry +from .const import DOMAIN +from .entity import LiteLLMEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LiteLLMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry in config_entry.get_subentries_of_type("conversation"): + async_add_entities( + [LiteLLMConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class LiteLLMConversationEntity(LiteLLMEntity, conversation.ConversationEntity): + """LiteLLM conversation agent.""" + + _attr_name = None + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + @override + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + @override + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process the user input and call the API.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/litellm/coordinator.py b/homeassistant/components/litellm/coordinator.py new file mode 100644 index 000000000000..ecd856bf6fd8 --- /dev/null +++ b/homeassistant/components/litellm/coordinator.py @@ -0,0 +1,74 @@ +"""Coordinator for the LiteLLM integration.""" + +from datetime import timedelta +from typing import override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, PLACEHOLDER_API_KEY + +# Ping the proxy hourly while it is reachable, and back off to once a minute +# while it is down so entities recover quickly once it returns. +UPDATE_INTERVAL_CONNECTED = timedelta(hours=1) +UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1) + +type LiteLLMConfigEntry = ConfigEntry[LiteLLMDataUpdateCoordinator] + + +class LiteLLMDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Own the OpenAI client and track LiteLLM proxy availability.""" + + config_entry: LiteLLMConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: LiteLLMConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL_CONNECTED, + always_update=False, + ) + self.client = AsyncOpenAI( + base_url=config_entry.data[CONF_URL], + api_key=config_entry.data.get(CONF_API_KEY) or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + + @override + async def _async_update_data(self) -> None: + """Ping the proxy to confirm it is reachable and authenticated.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + try: + async for _ in self.client.with_options(timeout=10.0).models.list(): + break + except (AuthenticationError, PermissionDeniedError) as err: + raise ConfigEntryAuthFailed from err + except OpenAIError as err: + raise UpdateFailed(err) from err + self.update_interval = UPDATE_INTERVAL_CONNECTED + + @callback + @override + def async_set_updated_data(self, data: None) -> None: + """Manually update data and reset to the connected interval.""" + self.update_interval = UPDATE_INTERVAL_CONNECTED + super().async_set_updated_data(data) + + @callback + def mark_connection_error(self) -> None: + """Flag the proxy as unreachable and schedule a quick recheck.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + if self.last_update_success: + self.last_update_success = False + self.async_update_listeners() + if self._listeners and not self.hass.is_stopping: + self._schedule_refresh() diff --git a/homeassistant/components/litellm/entity.py b/homeassistant/components/litellm/entity.py new file mode 100644 index 000000000000..dfd37a0e0f76 --- /dev/null +++ b/homeassistant/components/litellm/entity.py @@ -0,0 +1,211 @@ +"""Base entity for LiteLLM.""" + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LOGGER +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + unsupported_keys = {"oneOf", "anyOf", "allOf"} + schema = convert(tool.parameters, custom_serializer=custom_serializer) + schema = {k: v for k, v in schema.items() if k not in unsupported_keys} + + tool_spec = FunctionDefinition( + name=tool.name, + parameters=schema, + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json_dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageFunctionToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json_dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the LiteLLM message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if tool_call.type == "function" + ] + yield data + + +class LiteLLMEntity(CoordinatorEntity[LiteLLMDataUpdateCoordinator]): + """Base entity for LiteLLM.""" + + _attr_has_entity_name = True + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + super().__init__(entry.runtime_data) + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + model_args = { + "model": self.model, + "user": chat_log.conversation_id, + } + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + coordinator = self.entry.runtime_data + client = coordinator.client + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create(**model_args) + except (openai.AuthenticationError, openai.PermissionDeniedError) as err: + # Re-check so the proxy is marked unavailable for the auth failure. + await coordinator.async_request_refresh() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.APIConnectionError as err: + coordinator.mark_connection_error() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.OpenAIError as err: + # Reachable but the request failed; keep the entity available. + coordinator.async_set_updated_data(None) + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + if not result.choices: + LOGGER.error("API returned empty choices") + raise HomeAssistantError("API returned empty response") + + result_message = result.choices[0].message + + model_args["messages"].extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + coordinator.async_set_updated_data(None) + break + else: + LOGGER.warning( + "Stopped after %s tool iterations with unresolved tool calls", + MAX_TOOL_ITERATIONS, + ) diff --git a/homeassistant/components/litellm/manifest.json b/homeassistant/components/litellm/manifest.json new file mode 100644 index 000000000000..595ec0710b38 --- /dev/null +++ b/homeassistant/components/litellm/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "litellm", + "name": "LiteLLM", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@luismalves"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/litellm", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.45.0"] +} diff --git a/homeassistant/components/litellm/quality_scale.yaml b/homeassistant/components/litellm/quality_scale.yaml new file mode 100644 index 000000000000..448664369678 --- /dev/null +++ b/homeassistant/components/litellm/quality_scale.yaml @@ -0,0 +1,98 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: done + comment: >- + the coordinator polls the proxy hourly for an availability check, backing + off to once a minute while it is unreachable + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: done + comment: >- + the conversation entity follows the coordinator and is marked unavailable + when the proxy cannot be reached + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: the conversation entity does not use entity categories + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/litellm/strings.json b/homeassistant/components/litellm/strings.json new file mode 100644 index 000000000000..c13cf5122080 --- /dev/null +++ b/homeassistant/components/litellm/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "api_key": "An optional LiteLLM API key or virtual key. Leave empty if your proxy does not require authentication.", + "url": "The base URL of your LiteLLM proxy, including the host and port" + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "reconfigure": "Reconfigure conversation agent", + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.", + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + }, + "description": "Configure the conversation agent" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2cfe8887bdc9..861f54cdad7e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -429,6 +429,7 @@ FLOWS = { "lifx", "linkplay", "litejet", + "litellm", "litterrobot", "livisi", "llama_cpp", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1126febd6237..a4dfb7730d68 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3911,6 +3911,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "litellm": { + "name": "LiteLLM", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "litterrobot": { "name": "Whisker", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 6752fcf2621c..e9ddf4e4135d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3257,6 +3257,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.litellm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.litterrobot.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5a83382402a2..d512bbfab0d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1770,6 +1770,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.cloud +# homeassistant.components.litellm # homeassistant.components.llama_cpp # homeassistant.components.open_router # homeassistant.components.openai_conversation diff --git a/tests/components/litellm/__init__.py b/tests/components/litellm/__init__.py new file mode 100644 index 000000000000..27b3e0e89ba2 --- /dev/null +++ b/tests/components/litellm/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the LiteLLM integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def get_subentry_id(mock_config_entry: MockConfigEntry, subentry_type: str) -> str: + """Get the subentry ID for a given type.""" + ids = [ + subentry_id + for subentry_id, subentry in mock_config_entry.subentries.items() + if subentry.subentry_type == subentry_type + ] + if not ids: + raise ValueError(f"No subentry found for type {subentry_type}") + return ids[0] diff --git a/tests/components/litellm/conftest.py b/tests/components/litellm/conftest.py new file mode 100644 index 000000000000..84ebbac639ed --- /dev/null +++ b/tests/components/litellm/conftest.py @@ -0,0 +1,133 @@ +"""Fixtures for LiteLLM integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from openai.types import CompletionUsage, Model +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +TEST_URL = "http://localhost:4000/v1" + + +async def models_response(*model_ids: str) -> AsyncGenerator[Model]: + """Yield models as the OpenAI client's `models.list()` would.""" + for model_id in model_ids: + yield Model(id=model_id, created=0, object="model", owned_by="litellm") + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.litellm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def enable_assist() -> bool: + """Return whether the Assist LLM API is enabled for the conversation agent.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + conversation_subentry_data: dict[str, Any], +) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="localhost:4000", + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data=conversation_subentry_data, + subentry_id="ABCDEF", + subentry_type="conversation", + title="gpt-3.5-turbo", + unique_id=None, + ), + ], + ) + + +@pytest.fixture +async def mock_openai_client() -> AsyncGenerator[AsyncMock]: + """Mock the OpenAI client used for chat completions.""" + with patch( + "homeassistant.components.litellm.coordinator.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +def mock_models() -> Generator[AsyncMock]: + """Mock the OpenAI client the config flow uses to list proxy models.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-3.5-turbo", "gpt-4") + ) + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/litellm/snapshots/test_conversation.ambr b/tests/components/litellm/snapshots/test_conversation.ambr new file mode 100644 index 000000000000..a905dbed6dce --- /dev/null +++ b/tests/components/litellm/snapshots/test_conversation.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'What time is it?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock_tool_call_id', + 'tool_args': dict({ + }), + 'tool_name': 'HassGetCurrentTime', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', + 'tool_result': dict({ + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), + }), + }), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': '12:00 PM', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True].1 + list([ + dict({ + 'content': ''' + You are a helpful assistant. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'role': 'system', + }), + dict({ + 'content': 'What time is it?', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{}', + 'name': 'HassGetCurrentTime', + }), + 'id': 'mock_tool_call_id', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}', + 'role': 'tool', + 'tool_call_id': 'mock_tool_call_id', + }), + dict({ + 'content': '12:00 PM', + 'role': 'assistant', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{"param1":"call1"}', + 'name': 'test_tool', + }), + 'id': 'call_call_1', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '"value1"', + 'role': 'tool', + 'tool_call_id': 'call_call_1', + }), + dict({ + 'content': 'I have successfully called the function', + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/litellm/test_config_flow.py b/tests/components/litellm/test_config_flow.py new file mode 100644 index 000000000000..ac3a1675ab11 --- /dev/null +++ b/tests/components/litellm/test_config_flow.py @@ -0,0 +1,397 @@ +"""Test the LiteLLM config flow.""" + +from unittest.mock import AsyncMock, patch + +import httpx +from openai import ( + APIConnectionError, + APITimeoutError, + AuthenticationError, + PermissionDeniedError, +) +import pytest + +from homeassistant.components.litellm.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import get_subentry_id, setup_integration +from .conftest import TEST_URL, models_response + +from tests.common import MockConfigEntry + +CONVERSATION_MODEL_OPTIONS = [ + {"value": "gpt-3.5-turbo", "label": "gpt-3.5-turbo"}, + {"value": "gpt-4", "label": "gpt-4"}, +] + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +@pytest.mark.parametrize( + "url_input", + ["http://localhost:4000", "http://localhost:4000/", TEST_URL, f"{TEST_URL}/"], +) +async def test_full_flow(hass: HomeAssistant, url_input: str) -> None: + """Test the full config flow normalizes the URL and stores the key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url_input, CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "localhost" + assert result["data"] == {CONF_URL: TEST_URL, CONF_API_KEY: "bla"} + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +async def test_full_flow_without_api_key(hass: HomeAssistant) -> None: + """Test the config flow works without an API key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_URL: TEST_URL} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_errors( + hass: HomeAssistant, + exception: Exception, + error: str, +) -> None: + """Test we handle errors and can recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + ) as mock_get_models: + mock_get_models.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_get_models.side_effect = None + mock_get_models.return_value = {"gpt-3.5-turbo": {}} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +def _status_error( + error: type[AuthenticationError | PermissionDeniedError], status_code: int +) -> AuthenticationError | PermissionDeniedError: + """Build an OpenAI status error backed by a real httpx response.""" + return error( + response=httpx.Response( + status_code=status_code, request=httpx.Request("GET", TEST_URL) + ), + body=None, + message="error", + ) + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (_status_error(AuthenticationError, 401), "invalid_auth"), + (_status_error(PermissionDeniedError, 403), "invalid_auth"), + (APIConnectionError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + (APITimeoutError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + ], +) +async def test_user_step_proxy_errors( + hass: HomeAssistant, + side_effect: Exception, + error: str, +) -> None: + """Test the user step surfaces errors raised by the OpenAI client.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.side_effect = ( + side_effect + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_duplicate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry with the same URL already exists.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "other"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert ( + result["data_schema"].schema["model"].config["options"] + == CONVERSATION_MODEL_OPTIONS + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gpt-3.5-turbo" + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + } + + +async def test_conversation_agent_model_options( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the model dropdown is populated from the proxy's model list.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-4o", "gpt-5") + ) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-4o", "label": "gpt-4o"}, + {"value": "gpt-5", "label": "gpt-5"}, + ] + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_subentry_exceptions( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test subentry flow aborts on errors fetching models.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + side_effect=exception, + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-4", + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry.subentries[subentry_id] + assert subentry.title == "gpt-4" + assert subentry.data[CONF_MODEL] == "gpt-4" + assert subentry.data[CONF_PROMPT] == "updated prompt" + assert subentry.data[CONF_LLM_HASS_API] == ["assist"] + + +async def test_reconfigure_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring aborts when the main entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.parametrize( + ("current_llm_apis", "suggested_llm_apis", "expected_options"), + [ + (["assist"], ["assist"], ["assist"]), + (["non-existent"], [], ["assist"]), + (["assist", "non-existent"], ["assist"], ["assist"]), + ], +) +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_subentry_llm_api_schema( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + current_llm_apis: list[str], + suggested_llm_apis: list[str], + expected_options: list[str], +) -> None: + """Test llm_hass_api field values when reconfiguring a conversation subentry.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + subentry = mock_config_entry.subentries[subentry_id] + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_LLM_HASS_API: current_llm_apis}, + ) + await hass.async_block_till_done() + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == suggested_llm_apis + + field_schema = schema[key] + assert field_schema.config + assert [ + opt["value"] for opt in field_schema.config.get("options") + ] == expected_options diff --git a/tests/components/litellm/test_conversation.py b/tests/components/litellm/test_conversation.py new file mode 100644 index 000000000000..1f3660a3b98d --- /dev/null +++ b/tests/components/litellm/test_conversation.py @@ -0,0 +1,297 @@ +"""Tests for the LiteLLM conversation entity.""" + +import datetime +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import httpx +import openai +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import ToolInput + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + +AGENT_ID = "conversation.gpt_3_5_turbo" + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.litellm.PLATFORMS", + [Platform.CONVERSATION], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "gpt-3.5-turbo" + assert "extra_headers" not in call + + +async def test_empty_api_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an empty choices response raises an error.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8), + ) + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an error talking to the API is handled gracefully.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.OpenAIError("boom") + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_connection_error_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test a connection error marks the entity unavailable until it recovers.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.APIConnectionError( + request=httpx.Request("POST", "http://localhost") + ) + ) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + assert result.response.response_type is intent.IntentResponseType.ERROR + + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state == STATE_UNAVAILABLE + + # A successful availability ping restores the entity. + await mock_config_entry.runtime_data.async_request_refresh() + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.async_add_user_content( + conversation.UserContent(content="What time is it?") + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + tool_calls=[ + ToolInput( + tool_name="HassGetCurrentTime", + tool_args={}, + id="mock_tool_call_id", + external=True, + ) + ], + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.ToolResultContent( + agent_id=AGENT_ID, + tool_call_id="mock_tool_call_id", + tool_name="HassGetCurrentTime", + tool_result={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + }, + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + content="12:00 PM", + ) + ) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + mock_openai_client.chat.completions.create.side_effect = ( + ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot + assert mock_openai_client.chat.completions.create.call_count == 2 + assert ( + mock_openai_client.chat.completions.create.call_args.kwargs["messages"] + == snapshot + ) diff --git a/tests/components/litellm/test_init.py b/tests/components/litellm/test_init.py new file mode 100644 index 000000000000..151f783633dc --- /dev/null +++ b/tests/components/litellm/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the LiteLLM integration setup.""" + +from unittest.mock import AsyncMock + +import httpx +from openai import APIConnectionError, AuthenticationError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + AuthenticationError( + response=httpx.Response( + status_code=401, request=httpx.Request("GET", "http://localhost") + ), + body=None, + message="invalid api key", + ), + ConfigEntryState.SETUP_ERROR, + ), + (APIConnectionError(request=None), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test that setup handles errors validating the connection.""" + mock_openai_client.with_options.return_value.models.list.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state From 061d5fff940880901b2f3133e0ecb0a5be914d71 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 17 Jul 2026 12:05:02 +0200 Subject: [PATCH 682/707] Fix logging level per Mikrotik (#176655) --- homeassistant/components/mikrotik/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 8855df935101..8b41b852e3be 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -339,7 +339,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: _error = api_error if _error is not None: - _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], _error) + _LOGGER.debug("Mikrotik %s error: %s", entry[CONF_HOST], _error) if "invalid user name or password" in str(_error): raise LoginError from _error raise CannotConnect from _error From ee03b91dceb59f81201dab704c6d99a7e4c5d71e Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Fri, 17 Jul 2026 12:12:25 +0200 Subject: [PATCH 683/707] Mark Netatmo entities unavailable when device or service is unreachable (#176444) --- homeassistant/components/netatmo/button.py | 6 +- homeassistant/components/netatmo/camera.py | 2 + homeassistant/components/netatmo/climate.py | 9 +- .../components/netatmo/coordinator.py | 21 +++- homeassistant/components/netatmo/cover.py | 10 +- homeassistant/components/netatmo/entity.py | 19 ++++ homeassistant/components/netatmo/fan.py | 14 +-- homeassistant/components/netatmo/light.py | 21 ++-- .../components/netatmo/quality_scale.yaml | 2 +- homeassistant/components/netatmo/select.py | 1 + homeassistant/components/netatmo/sensor.py | 55 ++++++++--- homeassistant/components/netatmo/switch.py | 8 +- tests/components/netatmo/test_camera.py | 2 +- tests/components/netatmo/test_init.py | 97 ++++++++++++++++++- tests/components/netatmo/test_light.py | 3 +- tests/components/netatmo/test_switch.py | 59 ++++++++++- 16 files changed, 273 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index 3273023e8941..a2f41356e1ce 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ async def async_setup_entry( ) -class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): +class NetatmoCoverPreferredPositionButton(NetatmoReachabilityEntity, ButtonEntity): """Representation of a Netatmo cover preferred position button device.""" _attr_configuration_url = CONF_URL_CONTROL @@ -69,7 +69,7 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - # No state to update for button + self.async_write_ha_state() @override async def async_press(self) -> None: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index a05558a975d0..9ae69c6a2435 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -284,6 +284,8 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self.device.events ) + self.async_write_ha_state() + def process_events(self, event_list: list[NaEvent]) -> dict: """Add meta data to events.""" events = {} diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 177ef99a7efe..0b5fea41517a 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -290,6 +290,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): elif self._attr_preset_mode in [PRESET_SCHEDULE, PRESET_HOME]: self.async_update_callback() self.data_handler.async_force_update(self._signal_name) + return self.async_write_ha_state() return @@ -325,7 +326,6 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._attr_preset_mode = PRESET_MAP_NETATMO[PRESET_SCHEDULE] self.async_update_callback() - self.async_write_ha_state() return @property @@ -414,15 +414,16 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): @override def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" - return bool(self._connected) + return super().available and bool(self._connected) @callback @override def async_update_callback(self) -> None: """Update the entity's state.""" if not self.device.reachable: - if self.available: + if self._connected: self._connected = False + self.async_write_ha_state() return self._connected = True @@ -458,6 +459,8 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._boilerstatus = module.boiler_status break + self.async_write_ha_state() + async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None diff --git a/homeassistant/components/netatmo/coordinator.py b/homeassistant/components/netatmo/coordinator.py index db8523bd5960..34eb67f3a7ce 100644 --- a/homeassistant/components/netatmo/coordinator.py +++ b/homeassistant/components/netatmo/coordinator.py @@ -135,6 +135,7 @@ class NetatmoPublisher: subscriptions: set[CALLBACK_TYPE | None] method: str kwargs: dict + available: bool = True class NetatmoDataHandler: @@ -254,19 +255,29 @@ class NetatmoDataHandler: **self.publisher[signal_name].kwargs ) - except (pyatmo.NoDeviceError, pyatmo.ApiError) as err: + except ( + pyatmo.NoDeviceError, + pyatmo.ApiError, + TimeoutError, + aiohttp.ClientConnectorError, + ) as err: _LOGGER.debug(err) has_error = True - except (TimeoutError, aiohttp.ClientConnectorError) as err: - _LOGGER.debug(err) - return True + self.publisher[signal_name].available = not has_error + self._notify_subscribers(signal_name) + return has_error + def _notify_subscribers(self, signal_name: str) -> None: + """Notify all subscribers of a publisher to update their state.""" for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() - return has_error + def is_signal_available(self, signal_name: str) -> bool: + """Return whether the last fetch for a publisher succeeded.""" + publisher = self.publisher.get(signal_name) + return publisher is None or publisher.available async def subscribe( self, diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 089964e2ab1a..82c97c0c45c6 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class NetatmoCover(NetatmoModuleEntity, CoverEntity): +class NetatmoCover(NetatmoReachabilityEntity, CoverEntity): """Representation of a Netatmo cover device.""" _attr_supported_features = ( @@ -105,5 +105,7 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_closed = self.device.current_position == 0 - self._attr_current_cover_position = self.device.current_position + if self.device.reachable is not False: + self._attr_is_closed = self.device.current_position == 0 + self._attr_current_cover_position = self.device.current_position + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 97a378c203ac..ae301cb06fc9 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -35,6 +35,15 @@ class NetatmoBaseEntity(Entity): self._publishers: list[dict[str, Any]] = [] self._attr_extra_state_attributes = {} + @property + @override + def available(self) -> bool: + """Return True if the underlying data publishers are reachable.""" + return super().available and all( + self.data_handler.is_signal_available(publisher[SIGNAL_NAME]) + for publisher in self._publishers + ) + @override async def async_added_to_hass(self) -> None: """Entity created.""" @@ -174,6 +183,16 @@ class NetatmoModuleEntity(NetatmoDeviceEntity): return self.device.device_type +class NetatmoReachabilityEntity(NetatmoModuleEntity): + """Module entity that is unavailable when its device is unreachable.""" + + @property + @override + def available(self) -> bool: + """Return True unless the device explicitly reports as unreachable.""" + return super().available and self.device.reachable is not False + + class NetatmoWeatherModuleEntity(NetatmoModuleEntity): """Netatmo weather module entity base class.""" diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 6505eeda9eaf..0e4a4eb828a3 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class NetatmoFan(NetatmoModuleEntity, FanEntity): +class NetatmoFan(NetatmoReachabilityEntity, FanEntity): """Representation of a Netatmo fan.""" _attr_preset_modes = ["slow", "fast"] @@ -78,7 +78,9 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if self.device.fan_speed is None: - self._attr_preset_mode = None - return - self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + if self.device.reachable is not False: + if self.device.fan_speed is None: + self._attr_preset_mode = None + else: + self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index d132b0f75876..2e84133e1707 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -20,7 +20,7 @@ from .const import ( NETATMO_CREATE_LIGHT, ) from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoModuleEntity, NetatmoReachabilityEntity _LOGGER = logging.getLogger(__name__) @@ -124,7 +124,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity): @override def available(self) -> bool: """If the webhook is not established, mark as unavailable.""" - return bool(self.data_handler.webhook) + return super().available and bool(self.data_handler.webhook) @override async def async_turn_on(self, **kwargs: Any) -> None: @@ -143,9 +143,10 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity): def async_update_callback(self) -> None: """Update the entity's state.""" self._attr_is_on = bool(self.device.floodlight == "on") + self.async_write_ha_state() -class NetatmoLight(NetatmoModuleEntity, LightEntity): +class NetatmoLight(NetatmoReachabilityEntity, LightEntity): """Representation of a dimmable light by Legrand/BTicino.""" _attr_name = None @@ -200,10 +201,12 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on is True + if self.device.reachable is not False: + self._attr_is_on = self.device.on is True - if (brightness := self.device.brightness) is not None: - # Netatmo uses a range of [0, 100] to control brightness - self._attr_brightness = round(brightness * 2.55) - else: - self._attr_brightness = None + if (brightness := self.device.brightness) is not None: + # Netatmo uses a range of [0, 100] to control brightness + self._attr_brightness = round(brightness * 2.55) + else: + self._attr_brightness = None + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/quality_scale.yaml b/homeassistant/components/netatmo/quality_scale.yaml index 9896b2e2d8f4..d69785e45207 100644 --- a/homeassistant/components/netatmo/quality_scale.yaml +++ b/homeassistant/components/netatmo/quality_scale.yaml @@ -34,7 +34,7 @@ rules: config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: todo parallel-updates: done diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 78492fecd9a9..3527e8ae7dff 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -132,3 +132,4 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): self._attr_options = [ schedule.name for schedule in self.home.schedules.values() if schedule.name ] + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index b96292b245df..043b7cfb9842 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -62,6 +62,7 @@ from .coordinator import ( ) from .entity import ( NetatmoBaseEntity, + NetatmoDeviceEntity, NetatmoModuleEntity, NetatmoRoomEntity, NetatmoWeatherModuleEntity, @@ -631,7 +632,25 @@ async def async_setup_entry( await add_public_entities(False) -class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): +class NetatmoLegacyReachableSensor(NetatmoDeviceEntity, SensorEntity): + """Sensor mixin that goes unavailable, keeping its last value, when unreachable.""" + + @callback + def _async_set_unavailable_if_unreachable(self) -> bool: + """Set the entity unavailable and write state when the device is unreachable. + + Returns True when the device is unreachable so callers return early. + """ + device = cast("pyatmo.Module | pyatmo.Room", self.device) + if device.reachable: + return False + if self.available: + self._attr_available = False + self.async_write_ha_state() + return True + + +class NetatmoBaseSensor(NetatmoModuleEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -666,16 +685,11 @@ class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): """Update the entity's state (the legacy way).""" # Keep the last known value for these legacy sensors when the device is # unreachable to preserve the historical behavior expected by existing entities. - if not self.device.reachable: - if self.available: - self._attr_available = False - return - - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True - self._attr_native_value = state + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -700,7 +714,7 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, NetatmoBaseSensor): @override def available(self) -> bool: """Return True if entity is available.""" - return ( + return super().available and ( self.device.reachable or getattr( self.device, @@ -792,9 +806,7 @@ class NetatmoClimateBatterySensor(NetatmoLegacySensor): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if not self.device.reachable: - if self.available: - self._attr_available = False + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True @@ -861,7 +873,7 @@ class NetatmoSensor(NetatmoBaseSensor): self.async_write_ha_state() -class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity): +class NetatmoRoomSensor(NetatmoRoomEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo room sensor.""" entity_description: NetatmoSensorEntityDescription @@ -893,10 +905,11 @@ class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return - self._attr_native_value = state + self._attr_available = True + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -976,6 +989,17 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): self._signal_name = f"{PUBLIC}-{area.uuid}" self._mode = area.mode self._show_on_map = area.show_on_map + self._publishers = [ + { + "name": PUBLIC, + "lat_ne": area.lat_ne, + "lon_ne": area.lon_ne, + "lat_sw": area.lat_sw, + "lon_sw": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ] await self.data_handler.subscribe( PUBLIC, self._signal_name, @@ -1001,6 +1025,7 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): ) self._attr_available = False + self.async_write_ha_state() return if values := [x for x in data.values() if x is not None]: diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 357edb673685..8a07e7951dcd 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ async def async_setup_entry( ) -class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): +class NetatmoSwitch(NetatmoReachabilityEntity, SwitchEntity): """Representation of a Netatmo switch device.""" _attr_name = None @@ -70,7 +70,9 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on + if self.device.reachable is not False: + self._attr_is_on = self.device.on + self.async_write_ha_state() @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 1bc1542f23db..c26e18140ab6 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -697,7 +697,7 @@ async def test_setup_component_no_devices( """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_no_data(*args, **kwargs): + async def fake_post_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index d97ac9fd641c..9fbaf006169b 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -3,9 +3,11 @@ from datetime import timedelta from functools import partial from time import time +from typing import Any from unittest.mock import AsyncMock, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from pyatmo.const import ALL_SCOPES import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +15,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud, webhook from homeassistant.components.netatmo import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.const import ( + CONF_WEBHOOK_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ( OAuth2TokenRequestReauthError, @@ -113,7 +120,7 @@ async def test_setup_component_with_config( """Test setup of the netatmo component with dev account.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -656,3 +663,89 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("platform", "entity_id", "module_id", "initial_state"), + [ + pytest.param( + "switch", "switch.prise", "12:34:56:80:00:12:ac:f2", "on", id="switch" + ), + pytest.param( + "cover", "cover.entrance_blinds", "0009999992", "closed", id="cover" + ), + pytest.param( + "fan", + "fan.centralized_ventilation_controler", + "12:34:56:00:01:01:01:b1", + "on", + id="fan", + ), + pytest.param( + "light", + "light.unknown_00_11_22_33_00_11_45_fe", + "00:11:22:33:00:11:45:fe", + "off", + id="light", + ), + pytest.param( + "button", + "button.entrance_blinds_preferred_position", + "0009999992", + STATE_UNKNOWN, + id="button", + ), + ], +) +async def test_entity_unavailable_when_device_unreachable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + platform: str, + entity_id: str, + module_id: str, + initial_state: str, +) -> None: + """Test that entities become unavailable when their device is unreachable.""" + reachable = True + + def set_reachable(payload: dict) -> None: + home = payload.get("body", {}).get("home") + if not isinstance(home, dict): + return + for module in home.get("modules", []): + if module.get("id") == module_id: + module["reachable"] = reachable + + async def fake_post(*args: Any, **kwargs: Any): + return await fake_post_request( + hass, *args, msg_callback=set_reachable, **kwargs + ) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", [platform]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == initial_state + + reachable = False + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 4d3d339e4fe7..83fe5a54607a 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -1,5 +1,6 @@ """The tests for Netatmo light.""" +from typing import Any from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion @@ -113,7 +114,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_request_no_data(*args, **kwargs): + async def fake_post_request_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index fd7b09daa4f9..259a0703653c 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -1,7 +1,12 @@ """The tests for Netatmo switch.""" +from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +import pyatmo +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( @@ -9,13 +14,13 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import selected_platforms, snapshot_platform_entities +from .common import fake_post_request, selected_platforms, snapshot_platform_entities -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_entity( @@ -89,3 +94,51 @@ async def test_switch_setup_and_services( ] } ) + + +@pytest.mark.parametrize( + "error", + [TimeoutError, pyatmo.ApiError], + ids=["timeout", "api_error"], +) +async def test_switch_unavailable_on_fetch_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + error: type[Exception], +) -> None: + """Test the switch becomes unavailable when the data cannot be fetched.""" + raise_error = False + + async def fake_post(*args: Any, **kwargs: Any): + if raise_error: + raise error + return await fake_post_request(hass, *args, **kwargs) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["switch"]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + switch_entity = "switch.prise" + assert hass.states.get(switch_entity).state == "on" + + raise_error = True + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(switch_entity).state == STATE_UNAVAILABLE From 26f28685bbdee236b42df786642566a85132c5c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:13:33 +0200 Subject: [PATCH 684/707] Use modern device registry API to remove devices (part 2) (#176671) --- homeassistant/components/honeywell/climate.py | 6 ++---- homeassistant/components/hydrawise/coordinator.py | 10 +++------- homeassistant/components/ituran/coordinator.py | 4 +--- homeassistant/components/liebherr/__init__.py | 5 +---- homeassistant/components/matter/__init__.py | 4 +--- homeassistant/components/melcloud_home/coordinator.py | 4 +--- homeassistant/components/music_assistant/__init__.py | 4 +--- homeassistant/components/nest/__init__.py | 5 +---- homeassistant/components/netgear/__init__.py | 4 +--- homeassistant/components/nobo_hub/__init__.py | 4 +--- homeassistant/components/nordpool/__init__.py | 4 +--- 11 files changed, 14 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 3309e8931531..41baaf1a5823 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -152,10 +152,8 @@ def remove_stale_devices( # If device_id is None an invalid device entry was # found for this config entry. If the device_id is not # in existing device ids it's a stale device entry. - # Remove config entry from this device entry in either case. - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + # Remove the device entry in either case. + device_registry.async_remove_device(device_entry.id) class HoneywellUSThermostat(ClimateEntity): diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index c6f19c79e252..670ad3bcb54d 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -142,17 +142,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): if removed_zones := previous_zones - current_zones: LOGGER.debug("Removed zones: %s", ", ".join(removed_zones)) for zone_id in removed_zones: - device_registry.async_update_device( - device_id=previous_zones_by_id[zone_id].id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(previous_zones_by_id[zone_id].id) if removed_controllers := previous_controllers - current_controllers: LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers)) for controller_id in removed_controllers: - device_registry.async_update_device( - device_id=previous_controllers_by_id[controller_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_controllers_by_id[controller_id].id ) if new_controller_ids := current_controllers - previous_controllers: diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py index 2664e3e12d25..0cd48b263f05 100644 --- a/homeassistant/components/ituran/coordinator.py +++ b/homeassistant/components/ituran/coordinator.py @@ -73,6 +73,4 @@ class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]): ) for device in device_entries: if not device.identifiers.intersection(account_vehicles): - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 8f596768f197..577cd1d737ba 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -106,10 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> for device_id in device_ids: if coordinator := data.coordinators.pop(device_id, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[LiebherrCoordinator] = [] diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index de54159ad3b4..304b65315bfd 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -394,9 +394,7 @@ def _remove_via_devices( devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) for device in devices: if device.via_device_id == device_entry.id: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def async_remove_config_entry_device( diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py index f3d4f8ddb4cd..c24f246d8371 100644 --- a/homeassistant/components/melcloud_home/coordinator.py +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -102,9 +102,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) @override async def _async_update_data(self) -> UserContext: diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index f11d73a6af63..17f714a45ec2 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -248,9 +248,7 @@ async def async_setup_entry( # noqa: C901 for device in dev_entries: for identifier in device.identifiers: if identifier[0] == DOMAIN and identifier[1] not in player_ids: - dev_reg.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(device.id) return True diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index fec919b72372..174b8686a4aa 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -236,10 +236,7 @@ class SignalUpdateCallback: if device_id in devices: continue _LOGGER.info("Removing stale device entry '%s'", device_id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=self._config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool: diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index afc32d4c5be6..2212644bce60 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -94,9 +94,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> if device_entry.via_device_id is None: router_id = device_entry.id continue # do not remove the router itself - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Remove entities that are no longer tracked entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 1066d306349d..2529610da6c1 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -137,9 +137,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b device_registry, entry.entry_id ): if device.identifiers.isdisjoint(expected_identifiers): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) _cleanup_devices(hub) hub.register_callback(_cleanup_devices) diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 2b744e01d0da..6937f9f82022 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -66,6 +66,4 @@ async def cleanup_device( continue LOGGER.debug("Removing device %s", entry.name) - device_reg.async_update_device( - entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(entry.id) From 880fa628f82d863cac87eab8c14e1eb6f831679e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:14:01 +0200 Subject: [PATCH 685/707] Use modern device registry API to remove devices (part 1) (#176669) --- homeassistant/components/aidot/coordinator.py | 4 +--- homeassistant/components/aladdin_connect/__init__.py | 4 +--- homeassistant/components/bang_olufsen/event.py | 4 +--- homeassistant/components/bring/coordinator.py | 4 +--- homeassistant/components/fritz/coordinator.py | 4 +--- homeassistant/components/fritzbox/coordinator.py | 4 +--- homeassistant/components/growatt_server/__init__.py | 5 +---- homeassistant/components/home_connect/__init__.py | 4 +--- homeassistant/components/homee/__init__.py | 5 +---- 9 files changed, 9 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/aidot/coordinator.py b/homeassistant/components/aidot/coordinator.py index 7ec6a46ecd03..b751ac4af0f8 100644 --- a/homeassistant/components/aidot/coordinator.py +++ b/homeassistant/components/aidot/coordinator.py @@ -163,6 +163,4 @@ class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 516988da4510..1e5cf061a6bd 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -111,6 +111,4 @@ def remove_stale_devices( break if device_id and device_id not in all_device_ids: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index a8807a062add..625b742164ad 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -62,9 +62,7 @@ async def async_setup_entry( if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in { remote.serial_number for remote in remotes }: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async_add_entities(new_entities=entities) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 738d8d187fee..ee3be122bc55 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -176,9 +176,7 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]): diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index fcad98ef9f9e..aa043e825993 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -743,9 +743,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ): if not any(con in device.connections for con in valid_connections): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(device.id) fritz_data = self.hass.data[FRITZ_DATA_KEY] diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 496c04f2e055..1518ccfaa4df 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -121,9 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat ): if not set(device.identifiers) & identifiers: LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) def _update_fritz_devices(self) -> FritzboxCoordinatorData: """Update all fritzbox device data.""" diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 4435217a04ad..abf9118c8fa5 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -453,10 +453,7 @@ async def async_setup_entry( for device_sn in device_domain_ids: if coordinator := runtime_data.devices.pop(device_sn, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[GrowattCoordinator] = [] diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 44e475995ffb..c65e96298e01 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -93,9 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) for device in device_entries: if not device.identifiers.intersection(appliances_identifiers): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 01a7d3995344..dac324ea09ac 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -105,10 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo ) if not is_node_present: _LOGGER.info("Removing device %s", device.name) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) # Remove device at runtime when node is removed in homee async def _remove_node_callback(node: HomeeNode, add: bool) -> None: From a899b1edcbc202a396f688112614103c0dbddfcc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:14:25 +0200 Subject: [PATCH 686/707] Use modern device registry API to remove devices (part 3) (#176672) --- homeassistant/components/opower/sensor.py | 4 +--- homeassistant/components/proxmoxve/coordinator.py | 4 +--- homeassistant/components/ptdevices/coordinator.py | 4 +--- homeassistant/components/roborock/__init__.py | 5 +---- homeassistant/components/schlage/coordinator.py | 5 ++--- homeassistant/components/shelly/utils.py | 6 ++---- homeassistant/components/smartthings/__init__.py | 4 +--- homeassistant/components/sunricher_dali/__init__.py | 5 +---- homeassistant/components/swiss_public_transport/__init__.py | 4 +--- 9 files changed, 11 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 3bbaabf3b0f7..323b2ae28867 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -287,9 +287,7 @@ async def async_setup_entry( if entity_entry.config_entry_id != entry.entry_id: continue entity_registry.async_remove(entity_entry.entity_id) - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Prune sensor tracking for accounts that are no longer present if created_sensors: diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index b701fa975a8b..09b04b21d7a1 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -354,9 +354,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) class ProxmoxSetupError(Exception): diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py index 828034d089bd..6bb1b141610a 100644 --- a/homeassistant/components/ptdevices/coordinator.py +++ b/homeassistant/components/ptdevices/coordinator.py @@ -82,8 +82,6 @@ class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing stale device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return data["body"] diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 0472eb35d892..ada6df9a8469 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -183,10 +183,7 @@ def _remove_stale_devices( "Removing device: %s because it no longer exists in your account", device.name, ) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index f77df0155468..06b9bbb4b383 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -116,9 +116,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): if removed_locks := previous_locks - current_locks: LOGGER.debug("Removed locks: %s", ", ".join(removed_locks)) for lock_id in removed_locks: - device_registry.async_update_device( - device_id=previous_locks_by_lock_id[lock_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_locks_by_lock_id[lock_id].id ) if new_lock_ids := current_locks - previous_locks: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 9eccf34badc6..a6aebddbc023 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -916,7 +916,7 @@ def remove_stale_blu_trv_devices( continue LOGGER.debug("Removing stale BLU TRV device %s", device.name) - dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) + dev_reg.async_remove_device(device.id) @callback @@ -938,9 +938,7 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: if any(identifier[0] == DOMAIN for identifier in device.identifiers): LOGGER.debug("Removing empty sub-device %s", device.name) - dev_reg.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(device.id) def format_ble_addr(ble_addr: str) -> str: diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 82d8e751498d..1eb9559a2b8f 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -314,9 +314,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for device_identifier in device_status ): continue - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) return True diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index 6a13d3c5d1ef..c4012e10f588 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -59,10 +59,7 @@ def _remove_missing_devices( continue if domain_device_ids.isdisjoint(known_device_ids): - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index fe1e92ab6f26..c17a591e64c1 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -128,9 +128,7 @@ async def async_migrate_entry( device_registry, config_entry_id=config_entry.entry_id ) for dev in device_entries: - device_registry.async_update_device( - dev.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(dev.id) entity_id = entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, "None_departure" From b3b2e3563a11614f42196c0011ef4caddbe77454 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 12:14:51 +0200 Subject: [PATCH 687/707] Speed up entity_registry.async_entries_for_device (#176653) --- homeassistant/helpers/entity_registry.py | 32 +++++++++--------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 03a42c7cf5b9..092976d6e382 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1019,32 +1019,24 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): split devices. """ data = self.data - device_registry = dr.async_get(self._hass) - if device_id in device_registry.devices: - # Fast path: a live device id resolves directly to its own entities + if keys := self._device_id_index.get(device_id): + # Entities are indexed only under real (live or just-removed) device ids, + # never under a composite device id, so a non-empty bucket means the direct + # result is complete and the device registry can be skipped. return [ entry - for key in self._device_id_index.get(device_id, ()) + for key in keys if not (entry := data[key]).disabled_by or include_disabled_entities ] - # A pre-migration composite device id resolves to the entities of the split - # devices it was migrated into. device_id is kept in the list because the slow - # path is also hit for a device that was just removed (no longer in - # device_registry.devices) whose entities still need to be found - e.g. when the - # entity registry prunes the entities of a removed device. - device_ids = [ - device_id, - *( - device.id - for device in device_registry.async_get_devices_for_composite_device_id( - device_id - ) - ), - ] + # No directly indexed entities: device_id may be a pre-migration composite device + # id, which resolves to the entities of the split devices it was migrated into. + device_registry = dr.async_get(self._hass) return [ entry - for a_device_id in device_ids - for key in self._device_id_index.get(a_device_id, ()) + for device in device_registry.async_get_devices_for_composite_device_id( + device_id + ) + for key in self._device_id_index.get(device.id, ()) if not (entry := data[key]).disabled_by or include_disabled_entities ] From cea06f54ad61b8043e01f9fe24f4f677844aeb42 Mon Sep 17 00:00:00 2001 From: Niels Date: Fri, 17 Jul 2026 12:17:50 +0200 Subject: [PATCH 688/707] Add vibration conditions (#176598) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/vibration/condition.py | 25 +++ .../components/vibration/conditions.yaml | 26 +++ homeassistant/components/vibration/icons.json | 8 + .../components/vibration/strings.json | 28 +++ tests/components/vibration/test_condition.py | 196 ++++++++++++++++++ 5 files changed, 283 insertions(+) create mode 100644 homeassistant/components/vibration/condition.py create mode 100644 homeassistant/components/vibration/conditions.yaml create mode 100644 tests/components/vibration/test_condition.py diff --git a/homeassistant/components/vibration/condition.py b/homeassistant/components/vibration/condition.py new file mode 100644 index 000000000000..ab43c4593db5 --- /dev/null +++ b/homeassistant/components/vibration/condition.py @@ -0,0 +1,25 @@ +"""Provides conditions for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + + +CONDITIONS: dict[str, type[Condition]] = { + "is_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_ON), + "is_not_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for vibration.""" + return CONDITIONS diff --git a/homeassistant/components/vibration/conditions.yaml b/homeassistant/components/vibration/conditions.yaml new file mode 100644 index 000000000000..5f5bb66d8aaa --- /dev/null +++ b/homeassistant/components/vibration/conditions.yaml @@ -0,0 +1,26 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + +is_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +is_not_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/homeassistant/components/vibration/icons.json b/homeassistant/components/vibration/icons.json index 009711fd1655..d51de741bf87 100644 --- a/homeassistant/components/vibration/icons.json +++ b/homeassistant/components/vibration/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_detected": { + "condition": "mdi:vibrate" + }, + "is_not_detected": { + "condition": "mdi:vibrate-off" + } + }, "triggers": { "cleared": { "trigger": "mdi:vibrate-off" diff --git a/homeassistant/components/vibration/strings.json b/homeassistant/components/vibration/strings.json index 3e7d47b8dbfd..b1b3898251cf 100644 --- a/homeassistant/components/vibration/strings.json +++ b/homeassistant/components/vibration/strings.json @@ -1,8 +1,36 @@ { "common": { + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, + "conditions": { + "is_detected": { + "description": "Tests if one or more vibration sensors are detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is detected" + }, + "is_not_detected": { + "description": "Tests if one or more vibration sensors are not detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is not detected" + } + }, "title": "Vibration", "triggers": { "cleared": { diff --git a/tests/components/vibration/test_condition.py b/tests/components/vibration/test_condition.py new file mode 100644 index 000000000000..a81280df8147 --- /dev/null +++ b/tests/components/vibration/test_condition.py @@ -0,0 +1,196 @@ +"""Test vibration conditions.""" + +from typing import Any + +import pytest + +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_options_supported, + create_target_condition, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.is_detected", {}, True, True), + ("vibration.is_not_detected", {}, True, True), + ], +) +async def test_vibration_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_any( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_all( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ( + "condition_key", + "state_matching", + "state_non_matching", + ), + [ + ( + "vibration.is_detected", + STATE_ON, + STATE_OFF, + ), + ( + "vibration.is_not_detected", + STATE_OFF, + STATE_ON, + ), + ], +) +async def test_vibration_condition_excludes_non_vibration_device_class( + hass: HomeAssistant, + condition_key: str, + state_matching: str, + state_non_matching: str, +) -> None: + """Test vibration condition excludes entities without device_class vibration.""" + entity_id_vibration = "binary_sensor.test_vibration" + entity_id_motion = "binary_sensor.test_motion" + + hass.states.async_set( + entity_id_vibration, state_matching, {ATTR_DEVICE_CLASS: "vibration"} + ) + hass.states.async_set( + entity_id_motion, + state_matching, + {ATTR_DEVICE_CLASS: "motion"}, + ) + await hass.async_block_till_done() + + condition_any = await create_target_condition( + hass, + condition=condition_key, + target={CONF_ENTITY_ID: [entity_id_vibration, entity_id_motion]}, + behavior="any", + ) + + assert condition_any.async_check() is True + + hass.states.async_set( + entity_id_vibration, + state_non_matching, + {ATTR_DEVICE_CLASS: "vibration"}, + ) + await hass.async_block_till_done() + + assert condition_any.async_check() is False From 76ae70b792f31c996563c50a9c3c67ebd48b9fb9 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 17 Jul 2026 12:26:32 +0200 Subject: [PATCH 689/707] Add TextSelector in config flow SMA (#176660) --- homeassistant/components/sma/config_flow.py | 84 +++++++++++++-------- tests/components/sma/__init__.py | 1 + tests/components/sma/test_config_flow.py | 10 ++- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 77abd69ac833..694f4e98a6fb 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -27,6 +27,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_GROUP, DOMAIN, GROUPS @@ -34,6 +39,39 @@ from .const import CONF_GROUP, DOMAIN, GROUPS _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +STEP_DISCOVERY_CONFIRM_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + async def validate_input( hass: HomeAssistant, user_input: dict[str, Any], @@ -130,18 +168,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=self._data[CONF_HOST]): cv.string, - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input, ), errors=errors, ) @@ -172,20 +201,14 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SSL: user_input[CONF_SSL], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], CONF_GROUP: user_input[CONF_GROUP], + CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_GROUP): vol.In(GROUPS), - } - ), + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input or dict(reconf_entry.data), ), errors=errors, @@ -221,7 +244,12 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, @@ -290,17 +318,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", - data_schema=vol.Schema( - { - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_DISCOVERY_CONFIRM_DATA_SCHEMA, + suggested_values=user_input, ), description_placeholders={CONF_HOST: self._data[CONF_HOST]}, errors=errors, diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 99ae823dd973..e700daf95c04 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -40,6 +40,7 @@ MOCK_USER_RECONFIGURE = { CONF_SSL: True, CONF_VERIFY_SSL: False, CONF_GROUP: "user", + CONF_PASSWORD: "new_password", } diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 4c26fcb93175..0e4f8e5c6895 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -8,7 +8,13 @@ import pytest from homeassistant.components.sma.const import CONF_GROUP, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac @@ -338,6 +344,7 @@ async def test_full_flow_reconfigure( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 @@ -385,6 +392,7 @@ async def test_full_flow_reconfigure_exceptions( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 From e893a22cca647313cc5a195ed5d23af5d7b6ff7c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:29:52 +0200 Subject: [PATCH 690/707] Make repairs not persistent in FRITZ!Box Tools (#176623) --- homeassistant/components/fritz/button.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 684b5b3cbeb9..139db1a604fd 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -87,7 +87,7 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: domain=DOMAIN, issue_id="deprecated_cleanup_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_cleanup_button", translation_placeholders={"removal_version": "2026.11.0"}, @@ -114,7 +114,7 @@ def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) - domain=DOMAIN, issue_id="deprecated_firmware_update_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_firmware_update_button", translation_placeholders={"removal_version": "2026.11.0"}, From 0d3254deb448d2b450c9e58c12c9fd69d668238a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:43:51 +0200 Subject: [PATCH 691/707] Update ruff (#176645) Co-authored-by: Joostlek --- .pre-commit-config.yaml | 2 +- homeassistant/components/emby/media_player.py | 3 +-- pyproject.toml | 2 +- requirements_test_pre_commit.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 191130dd5c05..bd15be304e0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.20 + rev: v0.15.21 hooks: - id: ruff-check args: diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 2e920cf6cadd..0e214728c82d 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -18,7 +18,6 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_SSL, - DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) @@ -179,7 +178,7 @@ class EmbyDevice(MediaPlayerEntity): @override def name(self): """Return the name of the device.""" - return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME + return f"Emby {self.device.name}" @property @override diff --git a/pyproject.toml b/pyproject.toml index 76c8700a0ec9..9a4dfa5693ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -648,7 +648,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.15.20" +required-version = ">=0.15.21" [tool.ruff.lint] select = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8ef2ed276ee8..a4fecbd60d51 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.2 -ruff==0.15.20 +ruff==0.15.21 yamllint==1.38.0 zizmor==1.24.1 From 27ea3799531fdd4c2a093285358b4c699da9a2c4 Mon Sep 17 00:00:00 2001 From: derekcentrico <1930094+derekcentrico@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:37:12 -0400 Subject: [PATCH 692/707] Bump pyairnow to 1.4.0 for AirNow 2026 API endpoints (#176622) --- .../components/airnow/config_flow.py | 3 +- .../components/airnow/coordinator.py | 1 - homeassistant/components/airnow/manifest.json | 2 +- requirements_all.txt | 2 +- .../components/airnow/fixtures/response.json | 78 +++++++++---------- .../airnow/snapshots/test_diagnostics.ambr | 6 +- 6 files changed, 45 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 89ff2a45f9ac..3a0dfa49742e 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -38,11 +38,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: lat = data[CONF_LATITUDE] lng = data[CONF_LONGITUDE] - distance = data[CONF_RADIUS] # Check that the provided latitude/longitude provide a response try: - test_data = await client.observations.latLong(lat, lng, distance=distance) + test_data = await client.observations.latLong(lat, lng) except InvalidKeyError as exc: raise InvalidAuth from exc diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index f96c0e66a16e..020aecec00f8 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -77,7 +77,6 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): obs = await self.airnow.observations.latLong( self.latitude, self.longitude, - distance=self.distance, ) except (AirNowError, ClientConnectorError, InvalidJsonError) as error: diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index da1c936b68fb..fa321fe1a158 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.3.1"] + "requirements": ["pyairnow==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d512bbfab0d4..e2b2c16e1b28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2022,7 +2022,7 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.3.1 +pyairnow==1.4.0 # homeassistant.components.airobot pyairobotrest==0.4.0 diff --git a/tests/components/airnow/fixtures/response.json b/tests/components/airnow/fixtures/response.json index 91029f5531f2..63877e167a90 100644 --- a/tests/components/airnow/fixtures/response.json +++ b/tests/components/airnow/fixtures/response.json @@ -1,47 +1,47 @@ [ { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "O3", - "AQI": 44, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "OZONE", + "nowcastAQI": 44, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM2.5", - "AQI": 37, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM2.5", + "nowcastAQI": 37, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM10", - "AQI": 11, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM10", + "nowcastAQI": 11, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" } ] diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index d711f9c2eba1..72cb584adc6a 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -7,15 +7,15 @@ 'Category.Number': 1, 'DateObserved': '2020-12-20', 'HourObserved': 15, - 'Latitude': '**REDACTED**', + 'Latitude': None, 'LocalTimeZone': 'PST', - 'Longitude': '**REDACTED**', + 'Longitude': None, 'O3': 0.048, 'PM10': 12, 'PM2.5': 6.7, 'Pollutant': 'O3', 'ReportingArea': '**REDACTED**', - 'StateCode': '**REDACTED**', + 'StateCode': '', }), 'entry': dict({ 'data': dict({ From 176c77341da1f6839788c22e3b5f9a25a65d6135 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 14:30:04 +0200 Subject: [PATCH 693/707] Use modern device registry API for device move in wolflink (#176665) --- homeassistant/components/wolflink/__init__.py | 28 +++++----- tests/components/wolflink/test_init.py | 54 ++++++++++++++++++- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index d86047e323dc..1a94fc700194 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .const import DOMAIN, MANUFACTURER from .coordinator import WolflinkConfigEntry, WolfLinkCoordinator @@ -171,22 +172,21 @@ def _reattach_device_to_hub( if device is None: return - device_disabled_by = device.disabled_by - if device_disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY: + # The device registry will set the disabled_by flag to None when moving a + # device disabled by CONFIG_ENTRY to an enabled config entry, but we want + # to set it to USER instead. + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and hub_entry.disabled_by is None + ): device_disabled_by = dr.DeviceEntryDisabler.USER - if source_entry.entry_id != hub_entry.entry_id: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - add_config_entry_id=hub_entry.entry_id, - remove_config_entry_id=source_entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - ) + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_config_entry_id=hub_entry.entry_id, + ) for entity_entry in er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index 7576967bdf1a..7b10a4e8989b 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -11,7 +11,7 @@ from wolf_comm.token_auth import InvalidAuth from wolf_comm.wolf_client import FetchFailed, ParameterReadError from homeassistant.components.wolflink.const import DOMAIN, MANUFACTURER -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -252,6 +252,58 @@ async def test_migration_merges_duplicate_v1_entries( assert device.config_entries == {surviving.entry_id} +async def test_migration_merge_into_disabled_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an enabled device merged onto a disabled hub entry gets disabled.""" + hub_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + version=2, + minor_version=2, + disabled_by=ConfigEntryDisabler.USER, + ) + hub_entry.add_to_hass(hass) + legacy_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5678", + data={**LEGACY_CONFIG, "device_id": 5678}, + version=1, + minor_version=2, + ) + legacy_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=legacy_entry.entry_id, + identifiers={(DOMAIN, "5678")}, + manufacturer=MANUFACTURER, + name="test-device", + ) + + with patch( + "homeassistant.components.wolflink.WolfClient", + autospec=True, + ) as wolf_mock: + wolf_mock.return_value.fetch_system_list.side_effect = RequestError( + "Unable to connect" + ) + await hass.config_entries.async_setup(legacy_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].entry_id == hub_entry.entry_id + + # The device was reattached to the disabled hub entry, and its disabled + # state now reflects the new owning entry's disabled state. + migrated_device = device_registry.async_get(device.id) + assert migrated_device is not None + assert migrated_device.config_entries == {hub_entry.entry_id} + assert migrated_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + async def test_migration_v1_list_device_id(hass: HomeAssistant) -> None: """Test v1 migration tolerates device_id stored as a list from partial migrations.""" config_entry = MockConfigEntry( From 9a7107d78b8eb72e2296cc5537e5a5d6139deef3 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 17 Jul 2026 14:39:57 +0200 Subject: [PATCH 694/707] Bump python-duco-connectivity to 0.10.0 (#176685) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 49ee92e04bd4..ee7222fe9c28 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.9.0"], + "requirements": ["python-duco-connectivity==0.10.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/requirements_all.txt b/requirements_all.txt index e2b2c16e1b28..a6839b144a72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2653,7 +2653,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.9.0 +python-duco-connectivity==0.10.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 From ddf73de52d1a8e9d067a2903eb49b8204741b98c Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Fri, 17 Jul 2026 16:14:19 +0200 Subject: [PATCH 695/707] Add manufacturer, model, and model_id filtering to entity filter selector (#162989) --- homeassistant/helpers/selector.py | 27 +++++++++++++++++++++-- tests/helpers/test_selector.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 5936aadf2cd0..268cc67e8490 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -245,6 +245,26 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): model_id: str +ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = ( + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + # Filter on properties of the device the entity belongs to + vol.Optional("device"): DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA, + } + ) +) + + +class EntityWithDeviceFilterSelectorConfig(EntityFilterSelectorConfig, total=False): + """Class to represent an entity selector filter config. + + Adds device filtering on top of the shared entity filter, only used by + the entity selector. + """ + + device: DeviceFilterSelectorConfig + + class ActionSelectorConfig(BaseSelectorConfig): """Class to represent an action selector config.""" @@ -985,7 +1005,10 @@ class EntitySelectorConfig( include_entities: list[str] multiple: bool reorder: bool - filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: ( + EntityWithDeviceFilterSelectorConfig + | list[EntityWithDeviceFilterSelectorConfig] + ) @SELECTORS.register("entity") @@ -1004,7 +1027,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): vol.Optional("reorder", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, - [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + [ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], ), } ), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 95bbaaad87fc..9d8a2e764487 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -300,6 +300,38 @@ def test_device_selector_schema_error(schema) -> None: ( { "filter": [ + { + "device": { + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + } + } + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + { + "domain": "binary_sensor", + "device": { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + }, + }, + { + "device": { + "integration": "matter", + "manufacturer": "other-mock-manuf", + "model": "other-mock-model", + "model_id": "other-mock-model_id", + }, + }, {"unit_of_measurement": "baguette"}, ] }, @@ -341,6 +373,10 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"unit_of_measurement": ["currywurst", "bratwurst"]}, # Invalid unit_of_measurement {"filter": [{"unit_of_measurement": 42}]}, + # Device properties must be grouped under the device key + {"filter": [{"manufacturer": "mock-manuf"}]}, + {"filter": [{"model": "mock-model"}]}, + {"filter": [{"model_id": "mock-model_id"}]}, # reorder can only be used when multiple is true {"reorder": True}, {"reorder": True, "multiple": False}, From cb6a5187de85358c185f8a670ae732a24e21f9da Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 16:48:52 +0200 Subject: [PATCH 696/707] Deprecate no longer working device helpers (#176696) --- homeassistant/helpers/device.py | 35 +++++++++++++----------- tests/helpers/test_device.py | 48 ++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 37 deletions(-) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index 2d90a9c7914b..af8e5908661f 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -3,6 +3,7 @@ from homeassistant.core import HomeAssistant, callback from . import device_registry as dr, entity_registry as er +from .frame import ReportBehavior, report_usage @callback @@ -41,13 +42,18 @@ def async_device_info_to_link_from_entity( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from an entity. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - return async_device_info_to_link_from_device_id( - hass, - async_entity_id_to_device_id(hass, entity_id_or_uuid), + report_usage( + "calls async_device_info_to_link_from_entity, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry = " + "async_entity_id_to_device(hass, source_entity_id) instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback @@ -57,18 +63,17 @@ def async_device_info_to_link_from_device_id( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from a device id. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - dev_reg = dr.async_get(hass) - - if device_id is None or (device := dev_reg.async_get(device_id=device_id)) is None: - return None - - return dr.DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, + report_usage( + "calls async_device_info_to_link_from_device_id, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry to the target device instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 262e700c29ed..5459020276c9 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -1,5 +1,7 @@ """Tests for the Device Utils.""" +from unittest.mock import patch + import pytest import voluptuous as vol @@ -103,7 +105,13 @@ async def test_device_info_to_link( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test for returning device info with device link information.""" + """The link helpers are deprecated and always return None. + + A device_info carrying another device's identifiers implicitly added the caller's + config entry to that device, which a single-config-entry device can't represent - it + would silently fork a duplicate instead. Entities still attach to another config + entry's device by setting entity.device_entry. + """ config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -112,7 +120,6 @@ async def test_device_info_to_link( connections={("mac", "30:31:32:33:34:00")}, config_entry_id=config_entry.entry_id, ) - assert device is not None # Source entity registry source_entity = entity_registry.async_get_or_create( @@ -125,33 +132,30 @@ async def test_device_info_to_link( await hass.async_block_till_done() assert entity_registry.async_get("sensor.test_source") is not None - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid=source_entity.entity_id - ) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } - - result = async_device_info_to_link_from_device_id(hass, device_id=device.id) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } + # No link device_info is returned, even for an existing entity and device + with patch("homeassistant.helpers.device.report_usage") as report_usage: + assert ( + async_device_info_to_link_from_entity( + hass, entity_id_or_uuid=source_entity.entity_id + ) + is None + ) + assert ( + async_device_info_to_link_from_device_id(hass, device_id=device.id) is None + ) + assert report_usage.call_count == 2 # With a non-existent entity id - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid="sensor.invalid" + assert ( + async_device_info_to_link_from_entity(hass, entity_id_or_uuid="sensor.invalid") + is None ) - assert result is None # With a non-existent device id - result = async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") is None # With a None device id - result = async_device_info_to_link_from_device_id(hass, device_id=None) - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id=None) is None async def test_remove_stale_device_links_keep_entity_device( From 1492fc3fd51e0411962d1af10ff45cbceafed7c7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 17 Jul 2026 17:05:20 +0200 Subject: [PATCH 697/707] Refactor imou to use entity descriptions (#176675) --- homeassistant/components/imou/button.py | 70 ++++++++++++---------- homeassistant/components/imou/camera.py | 42 +++++++++---- homeassistant/components/imou/entity.py | 9 +-- homeassistant/components/imou/switch.py | 79 +++++++++++++++---------- 4 files changed, 123 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py index 972dee03f3c3..dd7242ae2bc7 100644 --- a/homeassistant/components/imou/button.py +++ b/homeassistant/components/imou/button.py @@ -5,7 +5,11 @@ from typing import override from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,15 +27,6 @@ PARAM_PTZ_DOWN = "ptz_down" PARAM_PTZ_LEFT = "ptz_left" PARAM_PTZ_RIGHT = "ptz_right" -BUTTON_TYPES = ( - PARAM_RESTART_DEVICE, - PARAM_MUTE, - PARAM_PTZ_UP, - PARAM_PTZ_DOWN, - PARAM_PTZ_LEFT, - PARAM_PTZ_RIGHT, -) - PTZ_BUTTON_TYPES = ( PARAM_PTZ_UP, PARAM_PTZ_DOWN, @@ -39,20 +34,43 @@ PTZ_BUTTON_TYPES = ( PARAM_PTZ_RIGHT, ) -BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = { - PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART, -} +BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key=PARAM_RESTART_DEVICE, + device_class=ButtonDeviceClass.RESTART, + ), + ButtonEntityDescription( + key=PARAM_MUTE, + translation_key=PARAM_MUTE, + ), + ButtonEntityDescription( + key=PARAM_PTZ_UP, + translation_key=PARAM_PTZ_UP, + ), + ButtonEntityDescription( + key=PARAM_PTZ_DOWN, + translation_key=PARAM_PTZ_DOWN, + ), + ButtonEntityDescription( + key=PARAM_PTZ_LEFT, + translation_key=PARAM_PTZ_LEFT, + ), + ButtonEntityDescription( + key=PARAM_PTZ_RIGHT, + translation_key=PARAM_PTZ_RIGHT, + ), +) def _iter_buttons( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (button_type, device) pairs for supported buttons.""" +) -> list[tuple[ButtonEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported buttons.""" return [ - (button_type, device) + (description, device) for device in coordinator.devices - for button_type in device.buttons - if button_type in BUTTON_TYPES + for description in BUTTON_TYPES + if description.key in device.buttons ] @@ -67,8 +85,8 @@ async def async_setup_entry( def _add_buttons(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouButton(coordinator, button_type, device) - for button_type, device in _iter_buttons(coordinator) + ImouButton(coordinator, description, device) + for description, device in _iter_buttons(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,17 +104,7 @@ async def async_setup_entry( class ImouButton(ImouEntity, ButtonEntity): """Imou button entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou button entity.""" - super().__init__(coordinator, entity_type, device) - if device_class := BUTTON_DEVICE_CLASS.get(entity_type): - self._attr_device_class = device_class - self._attr_translation_key = None + entity_description: ButtonEntityDescription @override async def async_press(self) -> None: diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py index a06a413b80b0..79acdedc9c44 100644 --- a/homeassistant/components/imou/camera.py +++ b/homeassistant/components/imou/camera.py @@ -1,12 +1,17 @@ """Support for Imou camera entities.""" +from dataclasses import dataclass from typing import override from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,9 +28,25 @@ CAMERA_STREAM_RESOLUTION_SD = "SD" PYIMOUAPI_LIVE_PROTOCOL = "https" PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3 -CAMERA_TYPES = ( - ("camera_sd", CAMERA_STREAM_RESOLUTION_SD), - ("camera_hd", PARAM_HD), + +@dataclass(frozen=True, kw_only=True) +class ImouCameraEntityDescription(CameraEntityDescription): + """Describes an Imou camera entity.""" + + resolution: str + + +CAMERA_TYPES: tuple[ImouCameraEntityDescription, ...] = ( + ImouCameraEntityDescription( + key="camera_sd", + translation_key="camera_sd", + resolution=CAMERA_STREAM_RESOLUTION_SD, + ), + ImouCameraEntityDescription( + key="camera_hd", + translation_key="camera_hd", + resolution=PARAM_HD, + ), ) @@ -40,11 +61,11 @@ async def async_setup_entry( def _add_cameras(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouCamera(coordinator, entity_type, device, resolution) + ImouCamera(coordinator, description, device) for device in coordinator.devices if device.channel_id is not None if imou_device_identifier(device) in device_keys - for entity_type, resolution in CAMERA_TYPES + for description in CAMERA_TYPES ) coordinator.new_device_callbacks.append(_add_cameras) @@ -61,19 +82,18 @@ async def async_setup_entry( class ImouCamera(ImouEntity, Camera): """Representation of an Imou camera stream.""" + entity_description: ImouCameraEntityDescription _attr_supported_features = CameraEntityFeature.STREAM def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: ImouCameraEntityDescription, device: ImouHaDevice, - resolution: str, ) -> None: """Initialize the camera entity.""" - self._resolution = resolution Camera.__init__(self) - super().__init__(coordinator, entity_type, device) + super().__init__(coordinator, description, device) @override async def stream_source(self) -> str | None: @@ -81,7 +101,7 @@ class ImouCamera(ImouEntity, Camera): try: return await self.coordinator.device_manager.async_get_device_stream( self.device, - self._resolution, + self.entity_description.resolution, PYIMOUAPI_LIVE_PROTOCOL, ) except ImouException as err: diff --git a/homeassistant/components/imou/entity.py b/homeassistant/components/imou/entity.py index ea21763eb946..e9c25f64dbeb 100644 --- a/homeassistant/components/imou/entity.py +++ b/homeassistant/components/imou/entity.py @@ -5,6 +5,7 @@ from typing import override from pyimouapi.ha_device import DeviceStatus, ImouHaDevice from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier @@ -19,15 +20,15 @@ class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]): def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: EntityDescription, device: ImouHaDevice, ) -> None: """Initialize the Imou entity.""" super().__init__(coordinator) - self._entity_type = entity_type + self.entity_description = description + self._entity_type = description.key self._device_key = imou_device_identifier(device) - self._attr_unique_id = f"{self._device_key}${entity_type}" - self._attr_translation_key = entity_type + self._attr_unique_id = f"{self._device_key}${description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_key)}, name=device.channel_name or device.device_name, diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py index caed3462950b..be6b7764127d 100644 --- a/homeassistant/components/imou/switch.py +++ b/homeassistant/components/imou/switch.py @@ -5,7 +5,11 @@ from typing import Any, override from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,32 +31,53 @@ from .entity import ImouEntity PARALLEL_UPDATES = 0 -SWITCH_TYPES = ( - PARAM_AB_ALARM_SOUND, - PARAM_AUDIO_ENCODE_CONTROL, - PARAM_CLOSE_CAMERA, - PARAM_HEADER_DETECT, - PARAM_LIGHT, - PARAM_MOTION_DETECT, - PARAM_PLUG_SWITCH, - PARAM_WHITE_LIGHT, +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=PARAM_AB_ALARM_SOUND, + translation_key=PARAM_AB_ALARM_SOUND, + ), + SwitchEntityDescription( + key=PARAM_AUDIO_ENCODE_CONTROL, + translation_key=PARAM_AUDIO_ENCODE_CONTROL, + ), + SwitchEntityDescription( + key=PARAM_CLOSE_CAMERA, + translation_key=PARAM_CLOSE_CAMERA, + ), + SwitchEntityDescription( + key=PARAM_HEADER_DETECT, + translation_key=PARAM_HEADER_DETECT, + ), + SwitchEntityDescription( + key=PARAM_LIGHT, + translation_key=PARAM_LIGHT, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_MOTION_DETECT, + translation_key=PARAM_MOTION_DETECT, + ), + SwitchEntityDescription( + key=PARAM_PLUG_SWITCH, + translation_key=PARAM_PLUG_SWITCH, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_WHITE_LIGHT, + translation_key=PARAM_WHITE_LIGHT, + ), ) -SWITCH_DEVICE_CLASS: dict[str, SwitchDeviceClass] = { - PARAM_LIGHT: SwitchDeviceClass.SWITCH, - PARAM_PLUG_SWITCH: SwitchDeviceClass.SWITCH, -} - def _iter_switches( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (switch_type, device) pairs for supported switches.""" +) -> list[tuple[SwitchEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported switches.""" return [ - (switch_type, device) + (description, device) for device in coordinator.devices - for switch_type in device.switches - if switch_type in SWITCH_TYPES + for description in SWITCH_TYPES + if description.key in device.switches ] @@ -67,8 +92,8 @@ async def async_setup_entry( def _add_switches(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouSwitch(coordinator, switch_type, device) - for switch_type, device in _iter_switches(coordinator) + ImouSwitch(coordinator, description, device) + for description, device in _iter_switches(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,15 +111,7 @@ async def async_setup_entry( class ImouSwitch(ImouEntity, SwitchEntity): """Imou switch entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou switch entity.""" - super().__init__(coordinator, entity_type, device) - self._attr_device_class = SWITCH_DEVICE_CLASS.get(entity_type) + entity_description: SwitchEntityDescription @property @override From ccf7fcbe6ba00c1b020fea9d826a43fc072cf7ec Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 17 Jul 2026 17:07:20 +0200 Subject: [PATCH 698/707] Bump pyportainer to 1.0.42 (#176694) --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 9787cd141e7c..395fe0b96413 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pyportainer"], "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.38"] + "requirements": ["pyportainer==1.0.42"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6839b144a72..fd15fa146412 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2482,7 +2482,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.38 +pyportainer==1.0.42 # homeassistant.components.probe_plus pyprobeplus==1.1.2 From 773553e61b09b0bf28ab1953ee6bc53486dd499e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 17:34:40 +0200 Subject: [PATCH 699/707] Mitigate crash in ScannerEntity related to device registry changes (#176684) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/device_tracker/entity.py | 30 ++++-- .../components/device_tracker/test_entity.py | 91 +++++++++++++++++++ 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index c12320dcb16e..ffa8a1197fc6 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -707,17 +707,29 @@ class ScannerEntity( await super().async_internal_added_to_hass() return - # Attach entry to device - if self.registry_entry.device_id != device_entry.id: - self.registry_entry = er.async_get(self.hass).async_update_entity( - self.entity_id, device_id=device_entry.id + dev_reg = dr.async_get(self.hass) + # find_device_entry may return a synthesized pre-migration composite whose id is + # not a real device and can't be assigned to an entity; resolve it to the split + # owned by this config entry so we attach to a concrete device. + if device_entry.id not in dev_reg.devices: + device_entry = next( + ( + split + for split in dev_reg.async_get_devices_for_composite_device_id( + device_entry.id + ) + if split.config_entry_id == self.platform.config_entry.entry_id + ), + None, ) - # Attach device to config entry - if self.platform.config_entry.entry_id not in device_entry.config_entries: - dr.async_get(self.hass).async_update_device( - device_entry.id, - add_config_entry_id=self.platform.config_entry.entry_id, + # Attach entry to device + if ( + device_entry is not None + and self.registry_entry.device_id != device_entry.id + ): + self.registry_entry = er.async_get(self.hass).async_update_entity( + self.entity_id, device_id=device_entry.id ) # Do this last or else the entity registry update listener has been installed diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index 230398378a0e..c2ffa6bcfe4a 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -3,6 +3,7 @@ from collections.abc import Generator from typing import Any +import attr import pytest from homeassistant.components.device_tracker import ( @@ -1611,6 +1612,96 @@ async def test_register_mac_ignored( assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION +async def test_scanner_entity_attaches_to_split_of_composite_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a scanner entity attaches to its config entry's split device.""" + mac = TEST_MAC_ADDRESS + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + old_id = "composite00000000000000000000000" + own_split = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(TEST_DOMAIN, "own")}, + ) + other_split = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", "x")}, + ) + # Simulate a migration split: both devices share the pre-migration composite id + device_registry.devices[own_split.id] = attr.evolve( + own_split, composite_device_id=old_id + ) + device_registry.devices[other_split.id] = attr.evolve( + other_split, composite_device_id=old_id + ) + # async_get_device now resolves the shared MAC to the synthesized composite + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + assert old_id not in device_registry.devices + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.composite_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Attached to its own split, not the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id == own_split.id + + +async def test_scanner_entity_composite_device_without_own_split( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A composite with no split owned by the scanner's config entry attaches nothing. + + The composite id is not a real device and can't be assigned to an entity, so with no + split to resolve to the entity is added without a device instead of raising. + """ + mac = TEST_MAC_ADDRESS + other_entry_1 = MockConfigEntry(domain="other_1") + other_entry_1.add_to_hass(hass) + other_entry_2 = MockConfigEntry(domain="other_2") + other_entry_2.add_to_hass(hass) + old_id = "composite00000000000000000000000" + # Both splits belong to other config entries, none to the scanner's + for entry, identifier in ((other_entry_1, "one"), (other_entry_2, "two")): + split = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", identifier)}, + ) + device_registry.devices[split.id] = attr.evolve( + split, composite_device_id=old_id + ) + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + assert old_id not in device_registry.devices + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.composite_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Added without a device rather than raising on the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id is None + + async def test_connected_device_registered( hass: HomeAssistant, config_entry: MockConfigEntry, From 1917e8a877ca7de50f999f388f84f1276a76803b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 17 Jul 2026 11:44:46 -0400 Subject: [PATCH 700/707] Add Harbor Sleep integration (#176171) --- CODEOWNERS | 2 + homeassistant/components/harbor/__init__.py | 43 +++ .../components/harbor/config_flow.py | 128 ++++++++ homeassistant/components/harbor/const.py | 13 + .../components/harbor/coordinator.py | 176 +++++++++++ homeassistant/components/harbor/entity.py | 37 +++ homeassistant/components/harbor/icons.json | 15 + homeassistant/components/harbor/manifest.json | 12 + .../components/harbor/quality_scale.yaml | 73 +++++ homeassistant/components/harbor/sensor.py | 97 ++++++ homeassistant/components/harbor/strings.json | 59 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/harbor/__init__.py | 12 + tests/components/harbor/conftest.py | 118 +++++++ .../harbor/snapshots/test_init.ambr | 32 ++ .../harbor/snapshots/test_sensor.ambr | 289 ++++++++++++++++++ tests/components/harbor/test_config_flow.py | 231 ++++++++++++++ tests/components/harbor/test_init.py | 144 +++++++++ tests/components/harbor/test_sensor.py | 93 ++++++ 21 files changed, 1584 insertions(+) create mode 100644 homeassistant/components/harbor/__init__.py create mode 100644 homeassistant/components/harbor/config_flow.py create mode 100644 homeassistant/components/harbor/const.py create mode 100644 homeassistant/components/harbor/coordinator.py create mode 100644 homeassistant/components/harbor/entity.py create mode 100644 homeassistant/components/harbor/icons.json create mode 100644 homeassistant/components/harbor/manifest.json create mode 100644 homeassistant/components/harbor/quality_scale.yaml create mode 100644 homeassistant/components/harbor/sensor.py create mode 100644 homeassistant/components/harbor/strings.json create mode 100644 tests/components/harbor/__init__.py create mode 100644 tests/components/harbor/conftest.py create mode 100644 tests/components/harbor/snapshots/test_init.ambr create mode 100644 tests/components/harbor/snapshots/test_sensor.ambr create mode 100644 tests/components/harbor/test_config_flow.py create mode 100644 tests/components/harbor/test_init.py create mode 100644 tests/components/harbor/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index bfa4aad156b6..5b7f9b411a16 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -719,6 +719,8 @@ CLAUDE.md @home-assistant/core /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame /tests/components/hanna/ @bestycame +/homeassistant/components/harbor/ @Lash-L @afgarcia86 +/tests/components/harbor/ @Lash-L @afgarcia86 /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core diff --git a/homeassistant/components/harbor/__init__.py b/homeassistant/components/harbor/__init__.py new file mode 100644 index 000000000000..1f10679688d0 --- /dev/null +++ b/homeassistant/components/harbor/__init__.py @@ -0,0 +1,43 @@ +"""The Harbor integration.""" + +from harbor.config import HarborCameraConfig + +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN, PLATFORMS +from .coordinator import HarborConfigEntry, HarborCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Set up Harbor from a config entry.""" + coordinator = HarborCoordinator( + hass, + entry, + HarborCameraConfig( + serial=entry.data[CONF_SERIAL], + cert_pem=entry.data[CONF_CERT_PEM], + key_pem=entry.data[CONF_KEY_PEM], + ip_address=entry.data[CONF_IP_ADDRESS], + ), + ) + await coordinator.async_start() + try: + await coordinator.async_wait_until_ready() + except TimeoutError as err: + await coordinator.async_shutdown() + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Unload a Harbor config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.async_shutdown() + return unload_ok diff --git a/homeassistant/components/harbor/config_flow.py b/homeassistant/components/harbor/config_flow.py new file mode 100644 index 000000000000..05d222fea54c --- /dev/null +++ b/homeassistant/components/harbor/config_flow.py @@ -0,0 +1,128 @@ +"""Config flow for Harbor.""" + +from typing import Any, override + +from harbor.config import HarborCameraConfig +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers import selector + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN +from .coordinator import async_probe_camera + +SERIAL_LENGTH = 10 + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL): selector.TextSelector(selector.TextSelectorConfig()), + vol.Required(CONF_CERT_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_KEY_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_IP_ADDRESS): selector.TextSelector( + selector.TextSelectorConfig() + ), + } +) + + +def _validate_serial(value: str) -> bool: + """Validate the Harbor serial number.""" + return len(value) == SERIAL_LENGTH and value.isdigit() + + +def _validate_cert_pem(value: str) -> bool: + """Validate a Harbor client certificate PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN CERTIFICATE-----") and value.endswith( + "-----END CERTIFICATE-----" + ) + + +def _validate_key_pem(value: str) -> bool: + """Validate a Harbor private key PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN PRIVATE KEY-----") and value.endswith( + "-----END PRIVATE KEY-----" + ) + + +def _validate_credentials(cert_pem: str, key_pem: str) -> dict[str, str]: + """Validate cert/key PEM blobs and return any errors.""" + errors: dict[str, str] = {} + if not _validate_cert_pem(cert_pem): + errors[CONF_CERT_PEM] = "invalid_cert" + if not _validate_key_pem(key_pem): + errors[CONF_KEY_PEM] = "invalid_key" + return errors + + +class HarborConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Harbor.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors={}, + ) + + normalized = { + key: value.strip() if isinstance(value, str) else value + for key, value in user_input.items() + } + errors: dict[str, str] = {} + display_name: str | None = None + + serial = normalized[CONF_SERIAL] + if not _validate_serial(serial): + errors[CONF_SERIAL] = "invalid_serial" + + errors.update( + _validate_credentials(normalized[CONF_CERT_PEM], normalized[CONF_KEY_PEM]) + ) + + if not errors: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + config = HarborCameraConfig( + serial=serial, + cert_pem=normalized[CONF_CERT_PEM], + key_pem=normalized[CONF_KEY_PEM], + ip_address=normalized[CONF_IP_ADDRESS], + ) + try: + display_name = await async_probe_camera(config) + except TimeoutError: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors, + ) + + entry_data: dict[str, Any] = { + CONF_SERIAL: serial, + CONF_CERT_PEM: normalized[CONF_CERT_PEM], + CONF_KEY_PEM: normalized[CONF_KEY_PEM], + CONF_IP_ADDRESS: normalized[CONF_IP_ADDRESS], + } + + return self.async_create_entry( + title=display_name or f"Camera {serial}", + data=entry_data, + ) diff --git a/homeassistant/components/harbor/const.py b/homeassistant/components/harbor/const.py new file mode 100644 index 000000000000..f9b5670e332b --- /dev/null +++ b/homeassistant/components/harbor/const.py @@ -0,0 +1,13 @@ +"""Constants for the Harbor integration.""" + +from homeassistant.const import Platform + +DOMAIN = "harbor" +MANUFACTURER = "Harbor" +MODEL = "Harbor Camera" + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONF_CERT_PEM = "cert_pem" +CONF_KEY_PEM = "key_pem" +CONF_SERIAL = "serial" diff --git a/homeassistant/components/harbor/coordinator.py b/homeassistant/components/harbor/coordinator.py new file mode 100644 index 000000000000..55afb751b663 --- /dev/null +++ b/homeassistant/components/harbor/coordinator.py @@ -0,0 +1,176 @@ +"""Coordinator for Harbor.""" + +import asyncio +import logging +from typing import Any, override +from uuid import uuid4 + +from harbor.config import HarborCameraConfig +from harbor.devices.camera import HarborCamera +from harbor.mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient +from harbor.state import HarborDeviceState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import instance_id +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, MODEL + +LOGGER = logging.getLogger(__name__) + +type HarborConfigEntry = ConfigEntry[HarborCoordinator] + +# How long to wait for the first successful MQTT connection and the first +# device data to arrive before treating the camera as unreachable, both when +# validating the config flow and during setup. +CONNECT_TIMEOUT = 30.0 + + +async def _discard_message(topic: str, payload: Any) -> None: + """Ignore messages received while probing the connection.""" + + +async def async_probe_camera(config: HarborCameraConfig) -> str | None: + """Connect to a Harbor camera and return its friendly name, if any. + + Raises ``TimeoutError`` when no MQTT session can be established with the + camera. Returns the camera's configured display name, or ``None`` when the + camera is reachable but has no name (or does not answer the settings + request in time). + """ + connected = asyncio.Event() + + async def _on_connection_change(is_connected: bool) -> None: + if is_connected: + connected.set() + + client = HarborMQTTClient( + config=config, + # Subscribe to the responses topic so the get-settings reply can be + # matched to its pending request; without a subscription the reply + # never reaches the client and the request would time out. + topics=[f"cameras/{config.serial}/responses/#"], + message_handler=_discard_message, + client_id=f"{DOMAIN}-{config.serial}-probe-{uuid4().hex[:8]}", + on_connection_change=_on_connection_change, + connection_grace_period=0, + ) + await client.start() + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await connected.wait() + try: + settings = await client.get_settings() + except TimeoutError, ConnectionError: + return None + if settings.settings is None: + return None + return settings.settings.preference_display_name + finally: + await client.stop() + + +class HarborCoordinator(DataUpdateCoordinator[HarborDeviceState]): + """Own the MQTT transport and state for a single Harbor camera.""" + + config_entry: HarborConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: HarborConfigEntry, + config: HarborCameraConfig, + ) -> None: + """Initialize the Harbor coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{config.serial}", + ) + self._config = config + self.device = HarborCamera(config) + self.data = self.device.state + self.connected = False + self._ssl_context_cache: dict[str, Any] = {} + self._mqtt_client: HarborMQTTClient | None = None + self._connected_event = asyncio.Event() + self._data_event = asyncio.Event() + self._unsubscribe_updates = self.device.subscribe_updates( + self._handle_device_update + ) + + async def async_start(self) -> None: + """Start the Harbor MQTT client.""" + hass_instance_id = await instance_id.async_get(self.hass) + client_id = ( + f"{DOMAIN}-{hass_instance_id[:8]}-" + f"{self.config_entry.entry_id[:8]}-{self._config.serial}" + ) + self._mqtt_client = HarborMQTTClient( + config=self._config, + topics=self.device.get_topics(), + message_handler=self.device.handle_message, + client_id=client_id, + ssl_context_cache=self._ssl_context_cache, + on_connection_change=self._async_set_connected, + # Fetch the full settings snapshot on every (re)connection so the + # device name and settings-derived state populate immediately + # instead of waiting for the next heartbeat. + initial_commands=DEFAULT_INITIAL_COMMANDS, + ) + await self._mqtt_client.start() + + async def async_wait_until_ready(self) -> None: + """Wait for the first MQTT connection and the first device data. + + Registering entities only once the camera's first message has + arrived means the device registry sees the real name and firmware + from the start, instead of a placeholder that would otherwise + persist until the next reload. + + Raises ``TimeoutError`` if the camera does not connect and report + data in time. + """ + async with asyncio.timeout(CONNECT_TIMEOUT): + await self._connected_event.wait() + await self._data_event.wait() + + @override + async def async_shutdown(self) -> None: + """Stop the MQTT client and release device resources.""" + await super().async_shutdown() + if self._mqtt_client is not None: + await self._mqtt_client.stop() + self._mqtt_client = None + self._unsubscribe_updates() + self.device.shutdown() + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the Harbor camera.""" + state = self.data + return DeviceInfo( + identifiers={(DOMAIN, state.serial)}, + manufacturer=MANUFACTURER, + model=MODEL, + name=state.display_name or f"{MODEL} {state.serial}", + serial_number=state.serial, + sw_version=state.os_version, + ) + + def _handle_device_update(self, state: HarborDeviceState) -> None: + """Mirror a library device update into Home Assistant.""" + self._data_event.set() + self.async_set_updated_data(state) + + async def _async_set_connected(self, connected: bool) -> None: + """Propagate the MQTT connection state to entity availability.""" + if connected: + self._connected_event.set() + if self.connected == connected: + return + self.connected = connected + self.async_update_listeners() diff --git a/homeassistant/components/harbor/entity.py b/homeassistant/components/harbor/entity.py new file mode 100644 index 000000000000..b04a3b3269bf --- /dev/null +++ b/homeassistant/components/harbor/entity.py @@ -0,0 +1,37 @@ +"""Base entities for Harbor.""" + +from typing import override + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import HarborCoordinator + + +class HarborEntity(CoordinatorEntity[HarborCoordinator]): + """Base Harbor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HarborCoordinator, + unique_key: str, + ) -> None: + """Initialize the Harbor entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.serial}_{unique_key}" + + @override + @property + def available(self) -> bool: + """Return if the entity is currently available.""" + if not self.coordinator.connected: + return False + return self.coordinator.data.last_seen is not None + + @override + @property + def device_info(self) -> DeviceInfo: + """Return the device info for the backing Harbor device.""" + return self.coordinator.device_info diff --git a/homeassistant/components/harbor/icons.json b/homeassistant/components/harbor/icons.json new file mode 100644 index 000000000000..50c18c3b3a7e --- /dev/null +++ b/homeassistant/components/harbor/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "num_viewers": { + "default": "mdi:account-eye" + }, + "stream_quality": { + "default": "mdi:signal" + }, + "wifi_strength": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/harbor/manifest.json b/homeassistant/components/harbor/manifest.json new file mode 100644 index 000000000000..a9f927b12828 --- /dev/null +++ b/homeassistant/components/harbor/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "harbor", + "name": "Harbor Sleep", + "codeowners": ["@Lash-L", "@afgarcia86"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/harbor", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["harbor"], + "quality_scale": "bronze", + "requirements": ["harbor-python==1.2.1"] +} diff --git a/homeassistant/components/harbor/quality_scale.yaml b/homeassistant/components/harbor/quality_scale.yaml new file mode 100644 index 000000000000..9fb9660e4441 --- /dev/null +++ b/homeassistant/components/harbor/quality_scale.yaml @@ -0,0 +1,73 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: This integration is push-based via MQTT and does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: Entities receive updates via the coordinator and do not subscribe to events directly. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/harbor/sensor.py b/homeassistant/components/harbor/sensor.py new file mode 100644 index 000000000000..ee1d03260895 --- /dev/null +++ b/homeassistant/components/harbor/sensor.py @@ -0,0 +1,97 @@ +"""Sensor entities for Harbor.""" + +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import HarborConfigEntry, HarborCoordinator +from .entity import HarborEntity + +PARALLEL_UPDATES = 0 + +CAMERA_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="num_viewers", + translation_key="num_viewers", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="bitrate", + translation_key="bitrate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="wifi_strength", + translation_key="wifi_strength", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="stream_quality", + translation_key="stream_quality", + device_class=SensorDeviceClass.ENUM, + options=["excellent", "fair", "good", "poor"], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HarborConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Harbor sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HarborSensor(coordinator, description) for description in CAMERA_SENSORS + ) + + +class HarborSensor(HarborEntity, SensorEntity): + """A Harbor sensor entity.""" + + def __init__( + self, + coordinator: HarborCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the Harbor sensor.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @override + @property + def native_value(self) -> StateType: + """Return the current sensor value.""" + value = self.coordinator.data.values.get(self.entity_description.key) + if ( + self.entity_description.device_class == SensorDeviceClass.ENUM + and value == "unknown" + ): + # The library falls back to the literal string "unknown" for any + # enum value it doesn't recognize; surface that as no value + # rather than a bogus member of the options list. + return None + return value diff --git a/homeassistant/components/harbor/strings.json b/homeassistant/components/harbor/strings.json new file mode 100644 index 000000000000..1d4c0bae7c10 --- /dev/null +++ b/homeassistant/components/harbor/strings.json @@ -0,0 +1,59 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_cert": "The client certificate must be a valid PEM certificate", + "invalid_key": "The private key must be a valid PEM private key", + "invalid_serial": "The serial number must be exactly 10 digits" + }, + "step": { + "user": { + "data": { + "cert_pem": "Client certificate", + "ip_address": "[%key:common::config_flow::data::ip%]", + "key_pem": "Private key", + "serial": "Serial number" + }, + "data_description": { + "cert_pem": "Paste the client certificate from the Harbor app.", + "ip_address": "The local IP address of the Harbor device.", + "key_pem": "Paste the private key that matches the client certificate.", + "serial": "The 10-digit serial number printed on the Harbor device." + }, + "title": "Set up Harbor" + } + } + }, + "entity": { + "sensor": { + "bitrate": { + "name": "Bitrate" + }, + "num_viewers": { + "name": "Viewers", + "unit_of_measurement": "viewers" + }, + "stream_quality": { + "name": "Stream quality", + "state": { + "excellent": "Excellent", + "fair": "Fair", + "good": "Good", + "poor": "Poor" + } + }, + "wifi_strength": { + "name": "Wi-Fi strength", + "unit_of_measurement": "bars" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to the Harbor camera. It may be offline or unreachable." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 861f54cdad7e..5ef4c22897d6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -307,6 +307,7 @@ FLOWS = { "guntamatic", "habitica", "hanna", + "harbor", "harman_luxury", "harmony", "hdfury", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a4dfb7730d68..5b860e42de22 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2758,6 +2758,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "harbor": { + "name": "Harbor Sleep", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "hardkernel": { "name": "Hardkernel", "integration_type": "hardware", diff --git a/requirements_all.txt b/requirements_all.txt index fd15fa146412..b3ccff7f8185 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1230,6 +1230,9 @@ habluetooth==6.26.5 # homeassistant.components.hanna hanna-cloud==0.0.7 +# homeassistant.components.harbor +harbor-python==1.2.1 + # homeassistant.components.cloud hass-nabucasa==2.2.0 diff --git a/tests/components/harbor/__init__.py b/tests/components/harbor/__init__.py new file mode 100644 index 000000000000..592e9ed1a213 --- /dev/null +++ b/tests/components/harbor/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Harbor integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Harbor integration in Home Assistant.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/harbor/conftest.py b/tests/components/harbor/conftest.py new file mode 100644 index 000000000000..09b59dc3d250 --- /dev/null +++ b/tests/components/harbor/conftest.py @@ -0,0 +1,118 @@ +"""Common fixtures for the Harbor tests.""" + +from collections.abc import Awaitable, Callable, Generator +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import MockConfigEntry + +SERIAL = "1234567890" +CERT_PEM = "-----BEGIN CERTIFICATE-----\nMIIBdummy\n-----END CERTIFICATE-----" +KEY_PEM = "-----BEGIN PRIVATE KEY-----\nMIIBdummy\n-----END PRIVATE KEY-----" + +HEARTBEAT_TOPIC = f"cameras/{SERIAL}/events/heartbeat" +LIVEKIT_TOPIC = f"cameras/{SERIAL}/events/local_livekit_heartbeat" + +HEARTBEAT_PAYLOAD: dict[str, Any] = { + "temperature": 98.6, + "os_version": "1.2.3", + "settings": {"preference_display_name": "Nursery"}, +} +LIVEKIT_PAYLOAD: dict[str, Any] = { + "bitrate": 1234.5, + "network_bars": 3, + "stream_quality": "GOOD", + "viewers_by_identity_full": { + "viewer-1": {"identity": "alice"}, + "viewer-2": {"identity": "bob"}, + }, + "os_version": "1.2.3", + "app_version": "4.5.6", +} + + +def connection_callback( + mock_mqtt_client: AsyncMock, +) -> Callable[[bool], Awaitable[None]]: + """Return the on_connection_change callback the integration registered.""" + return mock_mqtt_client.call_args.kwargs["on_connection_change"] + + +async def emit_message( + mock_mqtt_client: AsyncMock, topic: str, payload: dict[str, Any] +) -> None: + """Deliver an MQTT message through the handler the integration registered.""" + await mock_mqtt_client.call_args.kwargs["message_handler"](topic, payload) + + +async def set_connected(mock_mqtt_client: AsyncMock, connected: bool) -> None: + """Drive the MQTT connection state the integration observes.""" + await connection_callback(mock_mqtt_client)(connected) + + +@pytest.fixture(autouse=True) +def mock_connect_timeout() -> Generator[None]: + """Patch the connect timeout so unreachable-camera tests run quickly.""" + with patch("homeassistant.components.harbor.coordinator.CONNECT_TIMEOUT", 0): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.harbor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mqtt_client() -> Generator[AsyncMock]: + """Mock the Harbor MQTT client, reporting a successful connection on start.""" + with patch( + "homeassistant.components.harbor.coordinator.HarborMQTTClient", + autospec=True, + ) as mock_client: + + async def _start() -> None: + await set_connected(mock_client, True) + # Setup waits for the first device message too; simulate the + # initial-commands response landing right after connect, the + # same way a real camera answers before any explicit test + # message. Empty so it doesn't set values tests don't expect. + await mock_client.call_args.kwargs["message_handler"](HEARTBEAT_TOPIC, {}) + + mock_client.return_value.start.side_effect = _start + # The config flow probes get-settings for the camera's friendly name; + # default to an unnamed camera so the title falls back to the serial. + mock_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name=None) + ) + yield mock_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock Harbor config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=SERIAL, + title=f"Camera {SERIAL}", + data={ + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) diff --git a/tests/components/harbor/snapshots/test_init.ambr b/tests/components/harbor/snapshots/test_init.ambr new file mode 100644 index 000000000000..101e73248cb6 --- /dev/null +++ b/tests/components/harbor/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'harbor', + '1234567890', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Harbor', + 'model': 'Harbor Camera', + 'model_id': None, + 'name': 'Nursery', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'sw_version': '1.2.3', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/harbor/snapshots/test_sensor.ambr b/tests/components/harbor/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..b24e5b1e26de --- /dev/null +++ b/tests/components/harbor/snapshots/test_sensor.ambr @@ -0,0 +1,289 @@ +# serializer version: 1 +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bitrate', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bitrate', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bitrate', + 'unique_id': '1234567890_bitrate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'data_rate', + : 'Harbor Camera 1234567890 Bitrate', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.5', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream quality', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Stream quality', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stream_quality', + 'unique_id': '1234567890_stream_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Harbor Camera 1234567890 Stream quality', + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.harbor_camera_1234567890_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Harbor Camera 1234567890 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.harbor_camera_1234567890_viewers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Viewers', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Viewers', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'num_viewers', + 'unique_id': '1234567890_num_viewers', + 'unit_of_measurement': 'viewers', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Viewers', + : , + : 'viewers', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_viewers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': '1234567890_wifi_strength', + 'unit_of_measurement': 'bars', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Wi-Fi strength', + : , + : 'bars', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/harbor/test_config_flow.py b/tests/components/harbor/test_config_flow.py new file mode 100644 index 000000000000..238d2a662749 --- /dev/null +++ b/tests/components/harbor/test_config_flow.py @@ -0,0 +1,231 @@ +"""Test the Harbor config flow.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import CERT_PEM, KEY_PEM, SERIAL, set_connected + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_mqtt_client") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the full user flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Camera {SERIAL}" + assert result["result"].unique_id == SERIAL + assert result["data"] == { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + } + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + assert client_id.startswith(f"{DOMAIN}-{SERIAL}-probe-") + assert client_id != f"{DOMAIN}-{SERIAL}-probe" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_uses_friendly_name( + hass: HomeAssistant, mock_mqtt_client: AsyncMock +) -> None: + """Test the entry is titled with the camera's friendly name when set.""" + mock_mqtt_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name="Nursery") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nursery" + + +@pytest.mark.parametrize( + ("user_input", "error_field", "error"), + [ + ( + { + CONF_SERIAL: "123", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: "abcdefghij", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: "not a cert", + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_CERT_PEM, + "invalid_cert", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: "not a key", + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_KEY_PEM, + "invalid_key", + ), + ], + ids=["short_serial", "non_digit_serial", "bad_cert", "bad_key"], +) +@pytest.mark.usefixtures("mock_mqtt_client", "mock_setup_entry") +async def test_user_flow_validation_errors( + hass: HomeAssistant, + user_input: dict[str, str], + error_field: str, + error: str, +) -> None: + """Test validation errors are surfaced and recoverable.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {error_field: error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the flow aborts when the serial is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the flow shows an error and recovers when the camera is unreachable.""" + # Start the probe client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # A subsequent connection succeeds and the entry is created. + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/harbor/test_init.py b/tests/components/harbor/test_init.py new file mode 100644 index 000000000000..ef0d69148f2a --- /dev/null +++ b/tests/components/harbor/test_init.py @@ -0,0 +1,144 @@ +"""Test the Harbor integration setup and coordinator.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.harbor.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + SERIAL, + emit_message, + set_connected, +) + +from tests.common import MockConfigEntry + +# The default test fixture reports no device data on connect, so the device +# keeps its placeholder name and the entity id derives from that. +_SENSOR = "sensor.harbor_camera_1234567890_temperature" + + +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a config entry loads, starts the client, and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_mqtt_client.return_value.start.called + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_uses_instance_scoped_mqtt_client_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup uses an MQTT client id unique to this HA instance.""" + await setup_integration(hass, mock_config_entry) + + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + + assert client_id.startswith(f"{DOMAIN}-") + assert client_id.endswith(f"-{SERIAL}") + assert client_id != f"{DOMAIN}-{SERIAL}" + + +async def test_setup_retry_when_unreachable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera never connects.""" + # Start the client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_retry_when_no_data_arrives( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera connects but never sends data.""" + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_availability_follows_connection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test entity availability tracks the MQTT connection.""" + await setup_integration(hass, mock_config_entry) + + # Setup waits for the first device message, so entities start available. + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # A repeated connected signal is a no-op and keeps entities available. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # Losing the connection flips entities back to unavailable. + await set_connected(mock_mqtt_client, False) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state == STATE_UNAVAILABLE + + # Reconnecting restores availability without needing fresh device data. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the device adopts the name and firmware from the first message. + + Setup waits for that first message before registering entities, so the + device is correct from the start instead of needing a later reload. + """ + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device == snapshot diff --git a/tests/components/harbor/test_sensor.py b/tests/components/harbor/test_sensor.py new file mode 100644 index 000000000000..79502050034a --- /dev/null +++ b/tests/components/harbor/test_sensor.py @@ -0,0 +1,93 @@ +"""Test the Harbor sensors.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + LIVEKIT_PAYLOAD, + LIVEKIT_TOPIC, + emit_message, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Harbor sensors report their values.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_missing_values_are_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test sensors without a value in the payload report unknown.""" + await setup_integration(hass, mock_config_entry) + + # Only the heartbeat arrives; sensors fed by the LiveKit message stay unknown. + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.harbor_camera_1234567890_temperature").state == "37.0" + ) + assert ( + hass.states.get("sensor.harbor_camera_1234567890_bitrate").state + == STATE_UNKNOWN + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_unexpected_enum_value_stays_valid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a stream quality outside the declared options surfaces as unknown. + + The library maps unrecognized enum values onto its own "unknown" member; + the sensor treats that as no value rather than exposing "unknown" as a + literal enum option. + """ + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.harbor_camera_1234567890_stream_quality" + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == "good" + + # The camera reports a stream quality outside the known set. + await emit_message( + mock_mqtt_client, + LIVEKIT_TOPIC, + {**LIVEKIT_PAYLOAD, "stream_quality": "DEGRADED"}, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNKNOWN From a868b498e2b6a02d97b5ba0db5ac1ad238972236 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jul 2026 17:51:13 +0200 Subject: [PATCH 701/707] Deprecate passing add_helper_config_entry_to_device to async_handle_source_entity_changes (#176701) --- .../components/derivative/__init__.py | 1 - .../components/generic_hygrostat/__init__.py | 1 - .../components/generic_thermostat/__init__.py | 1 - .../components/history_stats/__init__.py | 1 - .../components/integration/__init__.py | 1 - .../components/mold_indicator/__init__.py | 1 - .../components/statistics/__init__.py | 1 - .../components/switch_as_x/__init__.py | 1 - .../components/threshold/__init__.py | 1 - homeassistant/components/trend/__init__.py | 1 - .../components/utility_meter/__init__.py | 1 - homeassistant/helpers/helper_integration.py | 41 ++++++------ tests/helpers/test_helper_integration.py | 63 ++++++++++++++++++- 13 files changed, 84 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index ce593e5f8f8c..9814bb80b6d9 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 9af17b89c1ce..9540869b2765 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -105,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # humidifier's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index e2e997b9c11b..75f552b2850a 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -33,7 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # heater's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index ebfb13653254..35745d6ebfbb 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -78,7 +78,6 @@ async def async_setup_entry( entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index eb8650dc6490..1a0bf8401f76 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index d60b5f0c696d..77bbb507849f 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -37,7 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to the humidity sensor's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index 49dcb19ceb56..4de69276a9a2 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -35,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index ef0a5cc5e3a0..e44aa0da3b1d 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -60,7 +60,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_get_parent_device_id(hass, entity_id), diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 695d73859603..1be37133e03e 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index c5a8549e91c0..a3f721fe6689 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -34,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index a0e2c77341c6..8fb244b18df8 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -205,7 +205,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index c433040a6c56..ba9f5191b6c2 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -7,17 +7,18 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, valid_entity from . import device_registry as dr, entity_registry as er from .event import async_track_entity_registry_updated_event +from .frame import ReportBehavior, report_usage def async_handle_source_entity_changes( hass: HomeAssistant, *, - add_helper_config_entry_to_device: bool = True, helper_config_entry_id: str, set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, + **kwargs: Any, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. @@ -31,11 +32,9 @@ def async_handle_source_entity_changes( called. If the source entity is identified by a UUID, the helper config entry is reloaded. - Source entity moved to another device: The helper entity is updated to link - to the new device, and the helper config entry removed from the old device - and added to the new device. Then the helper config entry is reloaded. + to the new device. Then the helper config entry is reloaded. - Source entity removed from the device: The helper entity is updated to link - to no device, and the helper config entry removed from the old device. Then - the helper config entry is reloaded. + to no device. Then the helper config entry is reloaded. :param set_source_entity_id_or_uuid: A function which updates the source entity ID or UUID, e.g., in the helper config entry options. @@ -43,6 +42,22 @@ def async_handle_source_entity_changes( is removed. This can be used to clean up any resources related to the source entity or ask the user to select a new source entity. """ + if "add_helper_config_entry_to_device" in kwargs: + del kwargs["add_helper_config_entry_to_device"] + # Adding the helper's config entry to the source device is no longer supported + # now that a device belongs to a single config entry; the helper entities link to + # the source device via their device_id instead. + report_usage( + "calls async_handle_source_entity_changes with " + "add_helper_config_entry_to_device, which no longer has any effect", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", + ) + if kwargs: + raise TypeError( + "async_handle_source_entity_changes() got unexpected keyword arguments " + f"{', '.join(map(repr, kwargs))}" + ) async def async_registry_updated( event: Event[er.EventEntityRegistryUpdatedData], @@ -89,9 +104,8 @@ def async_handle_source_entity_changes( # No need to do any cleanup return - # The source entity has been moved to a different device, update the helper - # entities to link to the new device and the helper device to include the - # helper config entry + # The source entity has been moved to a different device; relink the helper + # entities to the new device. for helper_entity in entity_registry.entities.get_entries_for_config_entry_id( helper_config_entry_id ): @@ -100,17 +114,6 @@ def async_handle_source_entity_changes( helper_entity.entity_id, device_id=source_entity_entry.device_id ) - if add_helper_config_entry_to_device: - if source_entity_entry.device_id is not None: - device_registry.async_update_device( - source_entity_entry.device_id, - add_config_entry_id=helper_config_entry_id, - ) - - device_registry.async_update_device( - source_device_id, remove_config_entry_id=helper_config_entry_id - ) - source_device_id = source_entity_entry.device_id # Reload the config entry so the helper entity is recreated with diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 7b6d713419ce..77b6ae2d2dfb 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -1,7 +1,7 @@ """Tests for the helper entity helpers.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -213,6 +213,67 @@ def listen_entity_registry_events( return events +@pytest.mark.parametrize("add_helper_config_entry_to_device", [True, False]) +async def test_async_handle_source_entity_changes_deprecated_kwarg( + hass: HomeAssistant, + add_helper_config_entry_to_device: bool, +) -> None: + """The removed add_helper_config_entry_to_device kwarg is accepted but reported. + + It is swallowed by **kwargs so callers still passing it don't raise, and reported on + its presence rather than its value, since it no longer has any effect either way. + """ + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + add_helper_config_entry_to_device=add_helper_config_entry_to_device, + ) + unsub() + + report_usage.assert_called_once() + assert "add_helper_config_entry_to_device" in report_usage.call_args[0][0] + + +async def test_async_handle_source_entity_changes_rejects_unknown_kwarg( + hass: HomeAssistant, +) -> None: + """An unknown keyword argument still raises, as it did before **kwargs was added. + + **kwargs only exists to swallow the deprecated add_helper_config_entry_to_device; + anything else (e.g. a misspelling) must not be silently accepted. + """ + with pytest.raises(TypeError, match="unexpected keyword arguments 'unknown_kwarg'"): + async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + unknown_kwarg=True, + ) + + +async def test_async_handle_source_entity_changes_without_deprecated_kwarg( + hass: HomeAssistant, +) -> None: + """Not passing the removed add_helper_config_entry_to_device kwarg is not reported.""" + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + ) + unsub() + + report_usage.assert_not_called() + + @pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") From 7f493398c9807b59e7894b02b6d0528e7237e8de Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 17 Jul 2026 18:12:42 +0200 Subject: [PATCH 702/707] Update frontend to 20260624.6 (#176711) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pylint/plugins/pylint_home_assistant/generated/mdi_icons.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index db81df79958a..19724c285043 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.5"] + "requirements": ["home-assistant-frontend==20260624.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9d1303105b2c..176976e770e5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.5 +home-assistant-frontend==20260624.6 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 8cdb0231c569..bee392ebe525 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.5" +FRONTEND_VERSION: Final[str] = "20260624.6" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index b3ccff7f8185..ceb7f8943a28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1281,7 +1281,7 @@ hole==0.9.2 holidays==0.100 # homeassistant.components.frontend -home-assistant-frontend==20260624.5 +home-assistant-frontend==20260624.6 # homeassistant.components.conversation home-assistant-intents==2026.6.24 From 24a1d5956223760076e611e148ba74c88cf84fc5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 17 Jul 2026 18:14:07 +0200 Subject: [PATCH 703/707] Refresh add-on update entities after store reload through Supervisor API proxy (#176648) Co-authored-by: Claude --- .../components/hassio/coordinator.py | 5 ++ .../components/hassio/websocket_api.py | 14 ++++++ tests/components/hassio/test_websocket_api.py | 50 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 191d3b6e2338..0dd9e2da0187 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -1411,6 +1411,11 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) + async def async_refresh_after_store_reload(self) -> None: + """Refresh addon data when the store was already reloaded externally.""" + async with self._debounced_refresh.async_lock(): + await super()._async_refresh(log_failures=True) + async def force_addon_info_data_refresh(self, addon_slug: str) -> None: """Force refresh of addon info data for a specific addon.""" try: diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index ed3034437e1f..dea7dbfbd45a 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import ( from .config import HassioUpdateParametersDict from .const import ( + ADDONS_COORDINATOR, ATTR_DATA, ATTR_ENDPOINT, ATTR_METHOD, @@ -59,6 +60,10 @@ WS_NO_ADMIN_ENDPOINTS = re.compile( r")$" ) +# Endpoint that reloads the add-on store. Afterwards the add-on update +# entities must be refreshed so they don't report stale update information. +STORE_RELOAD_ENDPOINT = "/store/reload" + _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -159,6 +164,15 @@ async def websocket_supervisor_api( # sensitive information and the frontend does not require it for ingress. if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command): data.pop("options", None) + # Await so the frontend only sees the reload finish once the add-on + # update entities reflect the reloaded store. + if ( + command == STORE_RELOAD_ENDPOINT + and msg[ATTR_METHOD] == "post" + and (coordinator := hass.data.get(ADDONS_COORDINATOR)) + ): + await coordinator.async_refresh_after_store_reload() + connection.send_result(msg[WS_ID], data) diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index df4700467bce..3b666f9e430f 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -375,6 +375,56 @@ async def test_websocket_non_admin_user( assert msg["error"]["message"] == "Unauthorized" +async def test_websocket_store_reload_refreshes_update_entities( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, + addons_list: AsyncMock, +) -> None: + """Test add-on update entities refresh after a store reload via the API proxy.""" + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=False, + version_latest="2.0.0", + ) + ] + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component(hass, DOMAIN, {"hassio": {}}) + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").state == "off" + + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=True, + version_latest="2.0.1", + ) + ] + aioclient_mock.post( + "http://127.0.0.1/store/reload", json={"result": "ok", "data": {}} + ) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + { + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/store/reload", + ATTR_METHOD: "post", + } + ) + msg = await websocket_client.receive_json() + assert msg["success"] + + assert hass.states.get("update.test_update").state == "on" + supervisor_client.store.reload.assert_not_called() + + async def test_update_addon( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 39e32ce0b2817f489cc47326eb9e8eb054715c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 17 Jul 2026 17:53:06 +0100 Subject: [PATCH 704/707] Add playwright to e2e tests workflow (#176520) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/e2e-tests.yml | 56 ++++++++++++++++++++------------- .gitignore | 4 +++ .prettierignore | 1 + tests/e2e/onboarding.spec.ts | 10 ++++++ tests/e2e/package.json | 13 ++++++++ tests/e2e/playwright.config.ts | 21 +++++++++++++ tests/e2e/pnpm-lock.yaml | 52 ++++++++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/onboarding.spec.ts create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/pnpm-lock.yaml diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b3784dca600a..97fd2dfc6fdf 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -31,7 +31,6 @@ jobs: runs-on: ubuntu-24.04-arm env: BASE_URL: http://localhost:8123 - CURL_OPTS: --silent --max-time 10 services: homeassistant: image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images] @@ -44,28 +43,43 @@ jobs: --health-interval=5s --health-retries=60 steps: - - name: Check frontend is served - run: | - # Pre-onboarding, / redirects to /onboarding.html; --location follows it - status=$(curl $CURL_OPTS --location --output /dev/null --write-out '%{http_code}' "$BASE_URL/") - if [ "$status" -ne 200 ]; then - echo "::error::Expected HTTP 200 from frontend, got $status" - exit 1 - fi + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - name: Check onboarding API responds - run: | - curl $CURL_OPTS --fail "$BASE_URL/api/onboarding" \ - | jq -e 'type == "array" and length > 0' + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: tests/e2e/package.json - - name: Check container is still running - env: - CONTAINER: ${{ job.services.homeassistant.id }} - run: | - if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then - echo "::error::Container is no longer running after checks" - exit 1 - fi + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: tests/e2e/pnpm-lock.yaml + + - name: Install E2E test dependencies + working-directory: tests/e2e + run: pnpm install --frozen-lockfile + + - name: Install Playwright browser + working-directory: tests/e2e + run: pnpm exec playwright install --with-deps chromium + + - name: Run Playwright E2E tests + working-directory: tests/e2e + run: pnpm exec playwright test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-${{ matrix.arch }} + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ - name: Dump container logs if: always() diff --git a/.gitignore b/.gitignore index 9d8cbaf15e09..5fb2ad904d14 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,7 @@ pytest_buckets.txt .claude/worktrees/ .serena/ +# Playwright e2e tests +tests/e2e/node_modules/ +tests/e2e/playwright-report/ +tests/e2e/test-results/ diff --git a/.prettierignore b/.prettierignore index c63290996661..54c2d65e4d62 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js tests/components/lidarr/fixtures/initialize-wrong.js tests/fixtures/core/config/yaml_errors/ +tests/e2e/pnpm-lock.yaml diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 000000000000..d6431b3d1ae6 --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; + +test("fresh instance redirects to onboarding and renders the UI", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page).toHaveURL(/\/onboarding\.html/); + await expect(page.locator("ha-onboarding")).toBeVisible(); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 000000000000..bb69b43f05bd --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "home-assistant-e2e-tests", + "version": "1.0.0", + "description": "End-to-end browser tests for Home Assistant Core", + "private": true, + "packageManager": "pnpm@11.13.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 000000000000..f130b643c4c0 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.BASE_URL ?? "http://localhost:8123"; + +export default defineConfig({ + testDir: ".", + timeout: 30_000, + // Reruns a failed test once in CI to absorb transient startup flakiness. + retries: process.env.CI ? 1 : 0, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml new file mode 100644 index 000000000000..51cd78654eff --- /dev/null +++ b/tests/e2e/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 + +packages: + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 From ebd70b0cd7d3487c4c22fb13fd547d5647e78e08 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:53:19 -0400 Subject: [PATCH 705/707] Adjust code owner list for Sonos (#176723) --- CODEOWNERS | 4 ++-- homeassistant/components/sonos/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 5b7f9b411a16..5c6830e92882 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1719,8 +1719,8 @@ CLAUDE.md @home-assistant/core /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn /tests/components/songpal/ @rytilahti @shenxn -/homeassistant/components/sonos/ @jjlawren @peterager -/tests/components/sonos/ @jjlawren @peterager +/homeassistant/components/sonos/ @peterager @jjlawren +/tests/components/sonos/ @peterager @jjlawren /homeassistant/components/soundtouch/ @kroimon /tests/components/soundtouch/ @kroimon /homeassistant/components/spaceapi/ @fabaff diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 001f0c9e220e..90d59e0c7db4 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -2,7 +2,7 @@ "domain": "sonos", "name": "Sonos", "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], - "codeowners": ["@jjlawren", "@peterager"], + "codeowners": ["@peterager", "@jjlawren"], "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", From 24e1f1bd39286d6241899eee5e4de70124e33bab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:57:20 +0200 Subject: [PATCH 706/707] Bump actions/stale from 10.3.0 to 10.4.0 (#176591) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 06f1638125f6..91798343783d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: # - Issues # - No issues marked as no-stale or help-wanted - name: 60 days stale PRs policy and 90 days stale issue policy - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: repo-token: ${{ steps.token.outputs.token }} remove-stale-when-updated: true From dc8eb62d41ade706fa27ccfa8b6365e5ef69986b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 17 Jul 2026 20:02:23 +0100 Subject: [PATCH 707/707] Review full branch diff against base in ha-review skills (#176712) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .claude/skills/ha-pr-reviewer/SKILL.md | 2 +- .claude/skills/ha-review/SKILL.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/skills/ha-pr-reviewer/SKILL.md b/.claude/skills/ha-pr-reviewer/SKILL.md index 35c2ecd81781..05060b3de2f0 100644 --- a/.claude/skills/ha-pr-reviewer/SKILL.md +++ b/.claude/skills/ha-pr-reviewer/SKILL.md @@ -8,7 +8,7 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c ## Instructions: - Use 'gh pr view' to get the PR details and description. - Use 'gh pr diff' to see all the changes in the PR. -- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. +- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. Explicitly pass the PR's target/base branch to the `ha-review` skill (obtained via `gh pr view`) so it diffs against the correct base. - Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill. ## IMPORTANT: diff --git a/.claude/skills/ha-review/SKILL.md b/.claude/skills/ha-review/SKILL.md index f78cbe0dfd5f..12e7cb4318df 100644 --- a/.claude/skills/ha-review/SKILL.md +++ b/.claude/skills/ha-review/SKILL.md @@ -5,6 +5,9 @@ description: Reviews Home Assistant code changes and provides constructive feedb # Review Code Changes +## Scope: +- Unless instructed otherwise, review the full branch changes against the target branch. Resolve the base to an available ref (prefer `upstream/`, then `origin/`, then local ``) and review `git diff "$(git merge-base "$BASE_REF" HEAD)"..HEAD`; use `dev` as the default base. + ## Analyze the code changes for: - Code quality and style consistency - Potential bugs or issues