From 4071eb76c72a08045f74b424f7ddb78ea1f33ec3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 31 Mar 2025 18:33:45 +0200 Subject: [PATCH 001/217] Revert PR 136314 (Cleanup map references in lovelace) (#141928) * Revert PR 136314 (Cleanup map references in lovelace) * Update homeassistant/components/lovelace/__init__.py Co-authored-by: Martin Hjelmare * Fix dashboard creation * Update homeassistant/components/lovelace/__init__.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/lovelace/__init__.py | 32 +++++++++- tests/components/lovelace/test_init.py | 58 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 4d8472da9a2..c0262f42f6c 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components import frontend, websocket_api +from homeassistant.components import frontend, onboarding, websocket_api from homeassistant.config import ( async_hass_config_yaml, async_process_component_and_handle_errors, @@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.frame import report_usage from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util import slugify @@ -282,6 +283,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: STORAGE_DASHBOARD_UPDATE_FIELDS, ).async_setup(hass) + def create_map_dashboard() -> None: + """Create a map dashboard.""" + hass.async_create_task(_create_map_dashboard(hass, dashboards_collection)) + + if not onboarding.async_is_onboarded(hass): + onboarding.async_add_listener(hass, create_map_dashboard) + return True @@ -323,3 +331,25 @@ def _register_panel( kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON) frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) + + +async def _create_map_dashboard( + hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection +) -> None: + """Create a map dashboard.""" + translations = await async_get_translations( + hass, hass.config.language, "dashboard", {onboarding.DOMAIN} + ) + title = translations["component.onboarding.dashboard.map.title"] + + await dashboards_collection.async_create_item( + { + CONF_ALLOW_SINGLE_WORD: True, + CONF_ICON: "mdi:map", + CONF_TITLE: title, + CONF_URL_PATH: "map", + } + ) + + map_store = hass.data[LOVELACE_DATA].dashboards["map"] + await map_store.async_save({"strategy": {"type": "map"}}) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index f35f7369f93..4c7cc96504b 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -13,6 +13,16 @@ from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator +@pytest.fixture +def mock_onboarding_not_done() -> Generator[MagicMock]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding + + @pytest.fixture def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" @@ -23,6 +33,15 @@ def mock_onboarding_done() -> Generator[MagicMock]: yield mock_onboarding +@pytest.fixture +def mock_add_onboarding_listener() -> Generator[MagicMock]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_add_listener", + ) as mock_add_onboarding_listener: + yield mock_add_onboarding_listener + + async def test_create_dashboards_when_onboarded( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -41,6 +60,45 @@ async def test_create_dashboards_when_onboarded( assert response["result"] == [] +async def test_create_dashboards_when_not_onboarded( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + mock_add_onboarding_listener, + mock_onboarding_not_done, +) -> None: + """Test we automatically create dashboards when not onboarded.""" + client = await hass_ws_client(hass) + + assert await async_setup_component(hass, "lovelace", {}) + + # Call onboarding listener + mock_add_onboarding_listener.mock_calls[0][1][1]() + await hass.async_block_till_done() + + # List dashboards + await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "icon": "mdi:map", + "id": "map", + "mode": "storage", + "require_admin": False, + "show_in_sidebar": True, + "title": "Map", + "url_path": "map", + } + ] + + # List map dashboard config + await client.send_json_auto_id({"type": "lovelace/config", "url_path": "map"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"strategy": {"type": "map"}} + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_hass_data_compatibility( From ef989160af7c785e74b01ff938ca69a2ab70bfac Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 31 Mar 2025 19:08:21 +0200 Subject: [PATCH 002/217] Bump aiowebdav2 to 0.4.5 (#141934) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 260c569b72b..63d093745d1 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.4"] + "requirements": ["aiowebdav2==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf01275a876..25937c18b3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.4 +aiowebdav2==0.4.5 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e382193217..49800dbc973 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.4 +aiowebdav2==0.4.5 # homeassistant.components.webostv aiowebostv==0.7.3 From 28dbf6e3dcd36d6bcfd96c39d31e1fc0c38966c3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 31 Mar 2025 13:29:07 -0500 Subject: [PATCH 003/217] Add preannounce boolean for announce/start conversation (#141930) * Add preannounce boolean * Fix disabling preannounce in wizard * Fix casing * Fix type of preannounce_media_id * Adjust description of preannounce_media_id --- .../components/assist_satellite/__init__.py | 6 ++++-- .../components/assist_satellite/entity.py | 20 ++++++++++++------- .../components/assist_satellite/services.yaml | 10 ++++++++++ .../components/assist_satellite/strings.json | 16 +++++++++++---- .../assist_satellite/websocket_api.py | 2 +- .../assist_satellite/test_entity.py | 12 +++++------ .../esphome/test_assist_satellite.py | 8 ++++---- 7 files changed, 50 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index bc2157b10b2..3338f223bc9 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -60,7 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("message"): str, vol.Optional("media_id"): str, - vol.Optional("preannounce_media_id"): vol.Any(str, None), + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, } ), cv.has_at_least_one_key("message", "media_id"), @@ -75,7 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("start_message"): str, vol.Optional("start_media_id"): str, - vol.Optional("preannounce_media_id"): vol.Any(str, None), + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, vol.Optional("extra_system_prompt"): str, } ), diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 7b4c1b92d8c..dc20c7650d7 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -180,7 +180,8 @@ class AssistSatelliteEntity(entity.Entity): self, message: str | None = None, media_id: str | None = None, - preannounce_media_id: str | None = PREANNOUNCE_URL, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Play and show an announcement on the satellite. @@ -190,8 +191,8 @@ class AssistSatelliteEntity(entity.Entity): If media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce is True, a sound is played before the announcement. If preannounce_media_id is provided, it overrides the default sound. - If preannounce_media_id is None, no sound is played. Calls async_announce with message and media id. """ @@ -201,7 +202,9 @@ class AssistSatelliteEntity(entity.Entity): message = "" announcement = await self._resolve_announcement_media_id( - message, media_id, preannounce_media_id + message, + media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, ) if self._is_announcing: @@ -229,7 +232,8 @@ class AssistSatelliteEntity(entity.Entity): start_message: str | None = None, start_media_id: str | None = None, extra_system_prompt: str | None = None, - preannounce_media_id: str | None = PREANNOUNCE_URL, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, ) -> None: """Start a conversation from the satellite. @@ -239,8 +243,8 @@ class AssistSatelliteEntity(entity.Entity): If start_media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. - If preannounce_media_id is provided, it is played before the announcement. - If preannounce_media_id is None, no sound is played. + If preannounce is True, a sound is played before the start message or media. + If preannounce_media_id is provided, it overrides the default sound. Calls async_start_conversation. """ @@ -257,7 +261,9 @@ class AssistSatelliteEntity(entity.Entity): start_message = "" announcement = await self._resolve_announcement_media_id( - start_message, start_media_id, preannounce_media_id + start_message, + start_media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, ) if self._is_announcing: diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 7d334d6a8db..d88710c4c4e 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -15,6 +15,11 @@ announce: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: preannounce_media_id: required: false selector: @@ -40,6 +45,11 @@ start_conversation: required: false selector: text: + preannounce: + required: false + default: true + selector: + boolean: preannounce_media_id: required: false selector: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 2bb61516bca..b69711c7106 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -24,9 +24,13 @@ "name": "Media ID", "description": "The media ID to announce instead of using text-to-speech." }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the announcement." + }, "preannounce_media_id": { - "name": "Preannounce Media ID", - "description": "The media ID to play before the announcement." + "name": "Preannounce media ID", + "description": "Custom media ID to play before the announcement." } } }, @@ -46,9 +50,13 @@ "name": "Extra system prompt", "description": "Provide background information to the AI about the request." }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the start message or media." + }, "preannounce_media_id": { - "name": "Preannounce Media ID", - "description": "The media ID to play before the start message or media." + "name": "Preannounce media ID", + "description": "Custom media ID to play before the start message or media." } } } diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 0a95880706a..6f8b3d723ad 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -199,7 +199,7 @@ async def websocket_test_connection( hass.async_create_background_task( satellite.async_internal_announce( media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}", - preannounce_media_id=None, + preannounce=False, ), f"assist_satellite_connection_test_{msg['entity_id']}", ) diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 2b1cc78943f..8050b23f5ff 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -186,7 +186,7 @@ async def test_new_pipeline_cancels_pipeline( ("service_data", "expected_params"), [ ( - {"message": "Hello", "preannounce_media_id": None}, + {"message": "Hello", "preannounce": False}, AssistSatelliteAnnouncement( message="Hello", media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", @@ -199,7 +199,7 @@ async def test_new_pipeline_cancels_pipeline( { "message": "Hello", "media_id": "media-source://given", - "preannounce_media_id": None, + "preannounce": False, }, AssistSatelliteAnnouncement( message="Hello", @@ -210,7 +210,7 @@ async def test_new_pipeline_cancels_pipeline( ), ), ( - {"media_id": "http://example.com/bla.mp3", "preannounce_media_id": None}, + {"media_id": "http://example.com/bla.mp3", "preannounce": False}, AssistSatelliteAnnouncement( message="", media_id="http://example.com/bla.mp3", @@ -541,7 +541,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "extra_system_prompt": "Better system prompt", - "preannounce_media_id": None, + "preannounce": False, }, ( "mock-conversation-id", @@ -559,7 +559,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "start_media_id": "media-source://given", - "preannounce_media_id": None, + "preannounce": False, }, ( "mock-conversation-id", @@ -576,7 +576,7 @@ async def test_vad_sensitivity_entity_not_found( ( { "start_media_id": "http://example.com/given.mp3", - "preannounce_media_id": None, + "preannounce": False, }, ( "mock-conversation-id", diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 2254d24c9ac..3f6db1dd9c9 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1221,7 +1221,7 @@ async def test_announce_message( { "entity_id": satellite.entity_id, "message": "test-text", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) @@ -1311,7 +1311,7 @@ async def test_announce_media_id( { "entity_id": satellite.entity_id, "media_id": "https://www.home-assistant.io/resolved.mp3", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) @@ -1522,7 +1522,7 @@ async def test_start_conversation_message( { "entity_id": satellite.entity_id, "start_message": "test-text", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) @@ -1631,7 +1631,7 @@ async def test_start_conversation_media_id( { "entity_id": satellite.entity_id, "start_media_id": "https://www.home-assistant.io/resolved.mp3", - "preannounce_media_id": None, + "preannounce": False, }, blocking=True, ) From 1978e94aaacba92457c328a127cf4b0eb67d0eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 31 Mar 2025 19:32:24 +0100 Subject: [PATCH 004/217] Fix Whirlpool sensor icon definition (#141937) --- homeassistant/components/whirlpool/sensor.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index d0d13a128e2..3d38883b901 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,7 +1,5 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -65,8 +63,6 @@ CYCLE_FUNC = [ ] DOOR_OPEN = "door_open" -ICON_D = "mdi:tumble-dryer" -ICON_W = "mdi:washing-machine" _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) @@ -124,6 +120,7 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = ( key="timeremaining", translation_key="end_time", device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", ), ) @@ -161,10 +158,11 @@ class WasherDryerClass(SensorEntity): """Initialize the washer sensor.""" self._wd: WasherDryer = washer_dryer - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W + self._attr_icon = ( + "mdi:tumble-dryer" + if "dryer" in washer_dryer.appliance_info.data_model.lower() + else "mdi:washing-machine" + ) self.entity_description: WhirlpoolSensorEntityDescription = description self._attr_device_info = DeviceInfo( @@ -205,11 +203,6 @@ class WasherDryerTimeClass(RestoreSensor): """Initialize the washer sensor.""" self._wd: WasherDryer = washer_dryer - if washer_dryer.name == "dryer": - self._attr_icon = ICON_D - else: - self._attr_icon = ICON_W - self.entity_description: SensorEntityDescription = description self._running: bool | None = None self._attr_device_info = DeviceInfo( From a904df5bc24186474a1eb49ef22afc520cf8ff02 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 31 Mar 2025 21:03:13 +0200 Subject: [PATCH 005/217] Add common module to ProxymoxVE integration (#141941) add common module --- .../components/proxmoxve/__init__.py | 79 +---------------- homeassistant/components/proxmoxve/common.py | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+), 78 deletions(-) create mode 100644 homeassistant/components/proxmoxve/common.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 0db6ea28652..11fa530f47b 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -6,7 +6,6 @@ from datetime import timedelta from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI -from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError import voluptuous as vol @@ -25,6 +24,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm from .const import ( _LOGGER, CONF_CONTAINERS, @@ -219,80 +219,3 @@ def create_coordinator_container_vm( update_method=async_update_data, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - - -def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: - """Get the container or vm api data and return it formatted in a dictionary. - - It is implemented in this way to allow for more data to be added for sensors - in the future. - """ - - return {"status": status["status"], "name": status["name"]} - - -def call_api_container_vm( - proxmox: ProxmoxAPI, - node_name: str, - vm_id: int, - machine_type: int, -) -> dict[str, Any] | None: - """Make proper api calls.""" - status = None - - try: - if machine_type == TYPE_VM: - status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() - elif machine_type == TYPE_CONTAINER: - status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() - except (ResourceException, requests.exceptions.ConnectionError): - return None - - return status - - -class ProxmoxClient: - """A wrapper for the proxmoxer ProxmoxAPI client.""" - - _proxmox: ProxmoxAPI - - def __init__( - self, - host: str, - port: int, - user: str, - realm: str, - password: str, - verify_ssl: bool, - ) -> None: - """Initialize the ProxmoxClient.""" - - self._host = host - self._port = port - self._user = user - self._realm = realm - self._password = password - self._verify_ssl = verify_ssl - - def build_client(self) -> None: - """Construct the ProxmoxAPI client. - - Allows inserting the realm within the `user` value. - """ - - if "@" in self._user: - user_id = self._user - else: - user_id = f"{self._user}@{self._realm}" - - self._proxmox = ProxmoxAPI( - self._host, - port=self._port, - user=user_id, - password=self._password, - verify_ssl=self._verify_ssl, - ) - - def get_api_client(self) -> ProxmoxAPI: - """Return the ProxmoxAPI client.""" - return self._proxmox diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py new file mode 100644 index 00000000000..4173377377c --- /dev/null +++ b/homeassistant/components/proxmoxve/common.py @@ -0,0 +1,88 @@ +"""Commons for Proxmox VE integration.""" + +from __future__ import annotations + +from typing import Any + +from proxmoxer import ProxmoxAPI +from proxmoxer.core import ResourceException +import requests.exceptions + +from .const import TYPE_CONTAINER, TYPE_VM + + +class ProxmoxClient: + """A wrapper for the proxmoxer ProxmoxAPI client.""" + + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: + """Initialize the ProxmoxClient.""" + + self._host = host + self._port = port + self._user = user + self._realm = realm + self._password = password + self._verify_ssl = verify_ssl + + def build_client(self) -> None: + """Construct the ProxmoxAPI client. + + Allows inserting the realm within the `user` value. + """ + + if "@" in self._user: + user_id = self._user + else: + user_id = f"{self._user}@{self._realm}" + + self._proxmox = ProxmoxAPI( + self._host, + port=self._port, + user=user_id, + password=self._password, + verify_ssl=self._verify_ssl, + ) + + def get_api_client(self) -> ProxmoxAPI: + """Return the ProxmoxAPI client.""" + return self._proxmox + + +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: + """Get the container or vm api data and return it formatted in a dictionary. + + It is implemented in this way to allow for more data to be added for sensors + in the future. + """ + + return {"status": status["status"], "name": status["name"]} + + +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: + """Make proper api calls.""" + status = None + + try: + if machine_type == TYPE_VM: + status = proxmox.nodes(node_name).qemu(vm_id).status.current.get() + elif machine_type == TYPE_CONTAINER: + status = proxmox.nodes(node_name).lxc(vm_id).status.current.get() + except (ResourceException, requests.exceptions.ConnectionError): + return None + + return status From c5f75bc135eba018441eb47980b5d6ac27338563 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 31 Mar 2025 21:10:14 +0200 Subject: [PATCH 006/217] Import function instead of relying on `hass.component` in watergate (#141945) --- homeassistant/components/watergate/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index fd591215d8b..4f075a57228 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -15,6 +15,7 @@ from homeassistant.components.webhook import ( Response, async_generate_url, async_register, + async_unregister, ) from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant @@ -75,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: """Unload a config entry.""" webhook_id = entry.data[CONF_WEBHOOK_ID] - hass.components.webhook.async_unregister(webhook_id) + async_unregister(hass, webhook_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From b758dc202f55b1ded8ed4605442046a2a6f9648c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:10:24 -0400 Subject: [PATCH 007/217] Reload the ZBT-1 integration on USB state changes (#141287) * Reload the config entry when the ZBT-1 is unplugged * Register the USB event handler globally to react better to re-plugs * Fix existing unit tests * Add an empty `CONFIG_SCHEMA` * Add a unit test * Fix unit tests * Fix unit tests for Linux * Address most review comments * Address remaining review comments --- .../homeassistant_sky_connect/__init__.py | 48 ++++++- .../homeassistant_sky_connect/conftest.py | 10 ++ .../homeassistant_sky_connect/test_init.py | 131 +++++++++++++++++- 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index e8b8c3bb433..1770e902b0f 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -3,19 +3,63 @@ from __future__ import annotations import logging +import os.path from homeassistant.components.homeassistant_hardware.util import guess_firmware_info +from homeassistant.components.usb import USBDevice, async_register_port_event_callback from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT +from .const import DESCRIPTION, DEVICE, DOMAIN, FIRMWARE, FIRMWARE_VERSION, PRODUCT _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ZBT-1 integration.""" + + @callback + def async_port_event_callback( + added: set[USBDevice], removed: set[USBDevice] + ) -> None: + """Handle USB port events.""" + current_entries_by_path = { + entry.data[DEVICE]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + for device in added | removed: + path = device.device + entry = current_entries_by_path.get(path) + + if entry is not None: + _LOGGER.debug( + "Device %r has changed state, reloading config entry %s", + path, + entry, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + async_register_port_event_callback(hass, async_port_event_callback) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" + + # Postpone loading the config entry if the device is missing + device_path = entry.data[DEVICE] + if not await hass.async_add_executor_job(os.path.exists, device_path): + raise ConfigEntryNotReady + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index c5bfa4bd609..89ec292d879 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -47,3 +47,13 @@ def mock_zha_get_last_network_settings() -> Generator[None]: AsyncMock(return_value=None), ): yield + + +@pytest.fixture(autouse=True) +def mock_usb_path_exists() -> Generator[None]: + """Mock os.path.exists to allow the ZBT-1 integration to load.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index c467a9e0d60..f38ac158e71 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -1,15 +1,28 @@ """Test the Home Assistant SkyConnect integration.""" +from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.usb import ( + async_request_scan, + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: @@ -58,3 +71,119 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: } await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: + """Test setup failing when the USB port is missing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + # Set up the config entry + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_exists.return_value = True + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now it's ready + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_usb_device_reactivity(hass: HomeAssistant) -> None: + """Test setting up USB monitoring.""" + assert await async_setup_component(hass, "usb", {"usb": {}}) + + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "description": "SkyConnect v1.0", + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + # Now we make it available but do not wait + mock_exists.return_value = True + + with patch_scanned_serial_ports( + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="3c0ed67c628beb11b1cd64a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_request_scan(hass) + + # It loads immediately + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Wait for a bit for the USB scan debouncer to cool off + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=5)) + + # Unplug the stick + mock_exists.return_value = False + + with patch_scanned_serial_ports(return_value=[]): + await async_request_scan(hass) + + # The integration has reloaded and is now in a failed state + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 09e5fbb25839df2b2736c2200d695520fb7aa0d3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 31 Mar 2025 21:23:48 +0200 Subject: [PATCH 008/217] Update frontend to 20250331.0 (#141943) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_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 884436ad4db..ef974177947 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250328.0"] + "requirements": ["home-assistant-frontend==20250331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b90371fdb67..9c1172aedc9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250328.0 +home-assistant-frontend==20250331.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 25937c18b3d..cd273b8e3f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250328.0 +home-assistant-frontend==20250331.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49800dbc973..13a37c7e3a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250328.0 +home-assistant-frontend==20250331.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From b3379e19213240d8e820eaa8f53339a69fa20e61 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 31 Mar 2025 21:35:21 +0200 Subject: [PATCH 009/217] Bump async-upnp-client to 0.44.0 (#141946) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 82541476a02..119d1d31d52 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.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 17fc3dc27e8..0289d5100d6 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.43.0"], + "requirements": ["async-upnp-client==0.44.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 6a30efd64f8..5bb69e7f121 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==2.1.0", - "async-upnp-client==0.43.0" + "async-upnp-client==0.44.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 6e1fba8c3a3..93943b0a9ea 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.43.0"] + "requirements": ["async-upnp-client==0.44.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index df4daa8782c..62ee4ede7d9 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.43.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index cf7bc9c9035..07970cb25ca 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9c1172aedc9..23555c16a2a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 audioop-lts==0.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index cd273b8e3f0..794cda27366 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -514,7 +514,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13a37c7e3a7..f65756040b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -478,7 +478,7 @@ arcam-fmj==1.8.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.43.0 +async-upnp-client==0.44.0 # homeassistant.components.arve asyncarve==0.1.1 From 4a4458ec5bef7e94009865cef22b988ae1602c1a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 22:02:22 +0200 Subject: [PATCH 010/217] Replace "Open" with common state in `comelit` (#141949) --- homeassistant/components/comelit/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 496d62655a9..a738c837d1b 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -42,9 +42,9 @@ "sensor": { "zone_status": { "state": { + "open": "[%key:common::state::open%]", "alarm": "Alarm", "armed": "Armed", - "open": "Open", "excluded": "Excluded", "faulty": "Faulty", "inhibited": "Inhibited", From 363b88407c1d4bcf23543ece497f4995c946a602 Mon Sep 17 00:00:00 2001 From: Ben Jones Date: Tue, 1 Apr 2025 10:16:22 +1300 Subject: [PATCH 011/217] Handle empty or missing state values for MQTT light entities using 'template' schema (#141177) * check for empty or missing values when processing state messages for MQTT light entities using 'template' schema * normalise warning logs * add tests (one is still failing and I can't work out why) * fix test * improve test coverage after PR review * improve test coverage after PR review --- .../components/mqtt/light/schema_template.py | 159 ++++++++++++------ tests/components/mqtt/test_light_template.py | 106 ++++++++++++ 2 files changed, 217 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 595f072416b..f561f15fb51 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -62,6 +62,7 @@ from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, + PayloadSentinel, PublishPayloadType, ReceiveMessage, ) @@ -126,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] ] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] + _value_templates: dict[ + str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType] + ] _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] @@ -203,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): @callback def _state_received(self, msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" - state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) - if state == STATE_ON: + state_value = self._value_templates[CONF_STATE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not state_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty state value", msg.topic + ) + elif state_value == STATE_ON: self._attr_is_on = True - elif state == STATE_OFF: + elif state_value == STATE_OFF: self._attr_is_on = False - elif state == PAYLOAD_NONE: + elif state_value == PAYLOAD_NONE: self._attr_is_on = None else: - _LOGGER.warning("Invalid state value received") + _LOGGER.warning( + "Invalid state value '%s' received from %s", + state_value, + msg.topic, + ) if CONF_BRIGHTNESS_TEMPLATE in self._config: - try: - if brightness := int( - self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ): - self._attr_brightness = brightness - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, + brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not brightness_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty brightness value", + msg.topic, + ) + else: + try: + if brightness := int(brightness_value): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + except ValueError: + _LOGGER.warning( + "Invalid brightness value '%s' received from %s", + brightness_value, + msg.topic, ) - except ValueError: - _LOGGER.warning("Invalid brightness value received from %s", msg.topic) - if CONF_COLOR_TEMP_TEMPLATE in self._config: - try: - color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( - msg.payload + color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not color_temp_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color temperature value", + msg.topic, ) - self._attr_color_temp_kelvin = ( - int(color_temp) - if self._color_temp_kelvin - else color_util.color_temperature_mired_to_kelvin(int(color_temp)) - if color_temp != "None" - else None - ) - except ValueError: - _LOGGER.warning("Invalid color temperature value received") + else: + try: + self._attr_color_temp_kelvin = ( + int(color_temp_value) + if self._color_temp_kelvin + else color_util.color_temperature_mired_to_kelvin( + int(color_temp_value) + ) + if color_temp_value != "None" + else None + ) + except ValueError: + _LOGGER.warning( + "Invalid color temperature value '%s' received from %s", + color_temp_value, + msg.topic, + ) if ( CONF_RED_TEMPLATE in self._config and CONF_GREEN_TEMPLATE in self._config and CONF_BLUE_TEMPLATE in self._config ): - try: - red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) - green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) - blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) - if red == "None" and green == "None" and blue == "None": - self._attr_hs_color = None - else: - self._attr_hs_color = color_util.color_RGB_to_hs( - int(red), int(green), int(blue) - ) + red_value = self._value_templates[CONF_RED_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + green_value = self._value_templates[CONF_GREEN_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + blue_value = self._value_templates[CONF_BLUE_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not red_value or not green_value or not blue_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty color value", msg.topic + ) + elif red_value == "None" and green_value == "None" and blue_value == "None": + self._attr_hs_color = None self._update_color_mode() - except ValueError: - _LOGGER.warning("Invalid color value received") + else: + try: + self._attr_hs_color = color_util.color_RGB_to_hs( + int(red_value), int(green_value), int(blue_value) + ) + self._update_color_mode() + except ValueError: + _LOGGER.warning("Invalid color value received from %s", msg.topic) if CONF_EFFECT_TEMPLATE in self._config: - effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) - if ( - effect_list := self._config[CONF_EFFECT_LIST] - ) and effect in effect_list: - self._attr_effect = effect + effect_value = self._value_templates[CONF_EFFECT_TEMPLATE]( + msg.payload, + PayloadSentinel.NONE, + ) + if not effect_value: + _LOGGER.debug( + "Ignoring message from '%s' with empty effect value", msg.topic + ) + elif (effect_list := self._config[CONF_EFFECT_LIST]) and str( + effect_value + ) in effect_list: + self._attr_effect = str(effect_value) else: - _LOGGER.warning("Unsupported effect value received") + _LOGGER.warning( + "Unsupported effect value '%s' received from %s", + effect_value, + msg.topic, + ) @callback def _prepare_subscribe_topics(self) -> None: diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index b3a1c11c2b6..e2cc801e97d 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -1545,3 +1545,109 @@ async def test_rgb_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "effect_list": ["rainbow", "colorloop"], + "state_topic": "test-topic", + "state_template": "{{ value_json.state }}", + "brightness_template": "{{ value_json.brightness }}", + "color_temp_template": "{{ value_json.color_temp }}", + "red_template": "{{ value_json.color.red }}", + "green_template": "{{ value_json.color.green }}", + "blue_template": "{{ value_json.color.blue }}", + "effect_template": "{{ value_json.effect }}", + }, + ), + ) + ], +) +async def test_state_templates_ignore_missing_values( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that rendering of MQTT value template ignores missing values.""" + await mqtt_mock_entry() + + # turn on the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "on"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + + # update brightness and color temperature (with no state) + async_fire_mqtt_message( + hass, "test-topic", '{"brightness": 255, "color_temp": 145}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == ( + 246, + 244, + 255, + ) # temp converted to color + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") == 6896 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.317, 0.317) # temp converted to color + assert state.attributes.get("hs_color") == ( + 251.249, + 4.253, + ) # temp converted to color + + # update color + async_fire_mqtt_message( + hass, "test-topic", '{"color": {"red": 255, "green": 128, "blue": 64}}' + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update brightness + async_fire_mqtt_message(hass, "test-topic", '{"brightness": 128}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") is None + + # update effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "rainbow"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # invalid effect + async_fire_mqtt_message(hass, "test-topic", '{"effect": "invalid"}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 128, 64) + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("color_temp_kelvin") is None # rgb color has priority + assert state.attributes.get("effect") == "rainbow" + + # turn off the light + async_fire_mqtt_message(hass, "test-topic", '{"state": "off"}') + state = hass.states.get("light.test") + assert state.state == STATE_OFF + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None From 0abaaa0a06d79a6af3fafb36879d512868d6f28f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Mar 2025 11:23:02 -1000 Subject: [PATCH 012/217] Bump pydantic to 2.11.1 (#141951) --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 23555c16a2a..846ade07eaa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -129,7 +129,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.1 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index c7bb9b11b87..f7b04f0a6bd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a7 pre-commit==4.0.0 -pydantic==2.10.6 +pydantic==2.11.1 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 8cbb423966d..11698f01e45 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.10.6 +pydantic==2.11.1 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From a09213bce87eefb64ebb5b90e0bcfed988c4d1a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 31 Mar 2025 23:23:25 +0200 Subject: [PATCH 013/217] Replace "Start" and "Disable" with common actions in `hassio` (#141953) --- homeassistant/components/hassio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index a543dbc7f89..cc7cfdd5f2c 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -24,8 +24,8 @@ "fix_menu": { "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", "menu_options": { - "addon_execute_start": "Start", - "addon_disable_boot": "Disable" + "addon_execute_start": "[%key:common::action::start%]", + "addon_disable_boot": "[%key:common::action::disable%]" } } }, From a722912e05c64564e5d95d66a796f3490c496a71 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 1 Apr 2025 05:43:24 +0200 Subject: [PATCH 014/217] Add translations for flash options in `light.turn_on` action (#141950) --- homeassistant/components/light/services.yaml | 7 +++---- homeassistant/components/light/strings.json | 6 ++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 2a1fbd11afd..c59d9e22483 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -293,11 +293,10 @@ turn_on: - light.LightEntityFeature.FLASH selector: select: + translation_key: flash options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" + - long + - short turn_off: target: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 4a3b98ded46..d4b709f65aa 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -283,6 +283,12 @@ "yellow": "Yellow", "yellowgreen": "Yellow green" } + }, + "flash": { + "options": { + "short": "Short", + "long": "Long" + } } }, "services": { From aa7694e81c68d01ef35b00edc8dce262eb688ab6 Mon Sep 17 00:00:00 2001 From: Steven Stallion Date: Tue, 1 Apr 2025 00:29:09 -0500 Subject: [PATCH 015/217] Bump sensorpush-api to 2.1.2 (#141965) --- homeassistant/components/sensorpush_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json index ad817251fa1..6fd6513ad2d 100644 --- a/homeassistant/components/sensorpush_cloud/manifest.json +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["sensorpush_api", "sensorpush_ha"], "quality_scale": "bronze", - "requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.3.2"] + "requirements": ["sensorpush-api==2.1.2", "sensorpush-ha==1.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 794cda27366..e8d1e5ea77f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2712,7 +2712,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush sensorpush-ble==1.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f65756040b0..77a70929022 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2189,7 +2189,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush_cloud -sensorpush-api==2.1.1 +sensorpush-api==2.1.2 # homeassistant.components.sensorpush sensorpush-ble==1.7.1 From def50b255d7fb8a96f330a4ba362d302d5a2f30e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Mar 2025 20:31:25 -1000 Subject: [PATCH 016/217] Bump aiohttp to 3.11.15 (#141967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.14...v3.11.15 fixes #141855 fixes #141146 --- 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 846ade07eaa..3465b24fb2a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.14 +aiohttp==3.11.15 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 8900eab74be..b90738bdbef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.14", + "aiohttp==3.11.15", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 736736e8f20..bd3722b3617 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.14 +aiohttp==3.11.15 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 9c3b9eee2a12ab6ef6f6569da6dacff0d77af3a9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 08:52:31 +0200 Subject: [PATCH 017/217] Replace "a entity" with "an entity" in `isy994` user strings (#141972) --- homeassistant/components/isy994/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 8872226daba..6594c030f08 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -90,7 +90,7 @@ }, "get_zwave_parameter": { "name": "Get Z-Wave Parameter", - "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "Parameter", @@ -100,7 +100,7 @@ }, "set_zwave_parameter": { "name": "Set Z-Wave parameter", - "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", "fields": { "parameter": { "name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]", From 28c38e92d4c38cdd513a0ac0e53655ad20a32502 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 09:28:41 +0200 Subject: [PATCH 018/217] Fix typo "certificartes" in `fully_kiosk` (#141979) --- homeassistant/components/fully_kiosk/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index 5841456c034..fdfdf7910ae 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -2,7 +2,7 @@ "common": { "data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.", "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?", - "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates." + "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." }, "config": { "step": { From 3155c1cd4fe677a2baead4769d3d3d0f835d7aff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Apr 2025 11:01:13 +0200 Subject: [PATCH 019/217] Add tests for renault QuotaLimitException (#141985) --- tests/components/renault/conftest.py | 176 +++++++++--------------- tests/components/renault/test_sensor.py | 82 ++++++++++- 2 files changed, 144 insertions(+), 114 deletions(-) diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index 9be41eb7ba0..dd3c4896264 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,9 +1,8 @@ """Provide common Renault fixtures.""" -from collections.abc import Generator, Iterator +from collections.abc import AsyncGenerator, Generator import contextlib from types import MappingProxyType -from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -51,7 +50,7 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture(name="patch_renault_account") -async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: +async def patch_renault_account(hass: HomeAssistant) -> AsyncGenerator[RenaultAccount]: """Create a Renault account.""" renault_account = RenaultAccount( MOCK_ACCOUNT_ID, @@ -68,7 +67,7 @@ async def patch_renault_account(hass: HomeAssistant) -> RenaultAccount: @pytest.fixture(name="patch_get_vehicles") -def patch_get_vehicles(vehicle_type: str): +def patch_get_vehicles(vehicle_type: str) -> Generator[None]: """Mock fixtures.""" with patch( "renault_api.renault_account.RenaultAccount.get_vehicles", @@ -123,149 +122,100 @@ def _get_fixtures(vehicle_type: str) -> MappingProxyType: } +@contextlib.contextmanager +def patch_get_vehicle_data() -> Generator[dict[str, AsyncMock]]: + """Mock get_vehicle_data methods.""" + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_battery_status" + ) as get_battery_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode" + ) as get_charge_mode, + patch("renault_api.renault_vehicle.RenaultVehicle.get_cockpit") as get_cockpit, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status" + ) as get_hvac_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_location" + ) as get_location, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_lock_status" + ) as get_lock_status, + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_res_state" + ) as get_res_state, + ): + yield { + "battery_status": get_battery_status, + "charge_mode": get_charge_mode, + "cockpit": get_cockpit, + "hvac_status": get_hvac_status, + "location": get_location, + "lock_status": get_lock_status, + "res_state": get_res_state, + } + + @pytest.fixture(name="fixtures_with_data") -def patch_fixtures_with_data(vehicle_type: str): +def patch_fixtures_with_data(vehicle_type: str) -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures(vehicle_type) - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_no_data") -def patch_fixtures_with_no_data(): +def patch_fixtures_with_no_data() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" mock_fixtures = _get_fixtures("") - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - return_value=mock_fixtures["battery_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - return_value=mock_fixtures["charge_mode"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - return_value=mock_fixtures["cockpit"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - return_value=mock_fixtures["hvac_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - return_value=mock_fixtures["location"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - return_value=mock_fixtures["lock_status"], - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - return_value=mock_fixtures["res_state"], - ), - ): - yield - - -@contextlib.contextmanager -def _patch_fixtures_with_side_effect(side_effect: Any) -> Iterator[None]: - """Mock fixtures.""" - with ( - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_battery_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_charge_mode", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_cockpit", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_hvac_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_location", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_lock_status", - side_effect=side_effect, - ), - patch( - "renault_api.renault_vehicle.RenaultVehicle.get_res_state", - side_effect=side_effect, - ), - ): - yield + with patch_get_vehicle_data() as patches: + for key, value in patches.items(): + value.return_value = mock_fixtures[key] + yield patches @pytest.fixture(name="fixtures_with_access_denied_exception") -def patch_fixtures_with_access_denied_exception(): +def patch_fixtures_with_access_denied_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" access_denied_exception = exceptions.AccessDeniedException( "err.func.403", "Access is denied for this resource", ) - with _patch_fixtures_with_side_effect(access_denied_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = access_denied_exception + yield patches @pytest.fixture(name="fixtures_with_invalid_upstream_exception") -def patch_fixtures_with_invalid_upstream_exception(): +def patch_fixtures_with_invalid_upstream_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" invalid_upstream_exception = exceptions.InvalidUpstreamException( "err.tech.500", "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", ) - with _patch_fixtures_with_side_effect(invalid_upstream_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = invalid_upstream_exception + yield patches @pytest.fixture(name="fixtures_with_not_supported_exception") -def patch_fixtures_with_not_supported_exception(): +def patch_fixtures_with_not_supported_exception() -> Generator[dict[str, AsyncMock]]: """Mock fixtures.""" not_supported_exception = exceptions.NotSupportedException( "err.tech.501", "This feature is not technically supported by this gateway", ) - with _patch_fixtures_with_side_effect(not_supported_exception): - yield + with patch_get_vehicle_data() as patches: + for value in patches.values(): + value.side_effect = not_supported_exception + yield patches diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index d69ab5c0b7f..fb5fc205a7b 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,19 +1,25 @@ """Tests for Renault sensors.""" from collections.abc import Generator +import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from renault_api.kamereon.exceptions import QuotaLimitException from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import check_device_registry, check_entities_unavailable +from .conftest import _get_fixtures, patch_get_vehicle_data from .const import MOCK_VEHICLES +from tests.common import async_fire_time_changed + pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -150,3 +156,77 @@ async def test_sensor_not_supported( check_device_registry(device_registry, mock_vehicle["expected_device"]) assert len(entity_registry.entities) == 0 + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_during_setup( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_number_battery" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Test QuotaLimitException recovery, with new battery level + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" + + +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_sensor_throttling_after_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_type: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for Renault sensors with a throttling error during setup.""" + mock_fixtures = _get_fixtures(vehicle_type) + with patch_get_vehicle_data() as patches: + for key, get_data_mock in patches.items(): + get_data_mock.return_value = mock_fixtures[key] + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state + entity_id = "sensor.reg_number_battery" + assert hass.states.get(entity_id).state == "60" + + # Test QuotaLimitException state + for get_data_mock in patches.values(): + get_data_mock.side_effect = QuotaLimitException( + "err.func.wired.overloaded", "You have reached your quota limit" + ) + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Test QuotaLimitException recovery, with new battery level + for get_data_mock in patches.values(): + get_data_mock.side_effect = None + patches["battery_status"].return_value.batteryLevel = 55 + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "55" From 7a9836064d6615d8542c7bb8882bf591445e984b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 11:14:41 +0200 Subject: [PATCH 020/217] Replace "A entity" with "An entity" in `modbus` (#141973) * Replace "A entity" with "An entity" in `modbus` * Fix wrong commas --- homeassistant/components/modbus/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 347549dc837..7d1578558b0 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -88,11 +88,11 @@ }, "duplicate_entity_entry": { "title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.", - "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "duplicate_entity_name": { "title": "Modbus {sub_1} is duplicate, second entry not loaded.", - "description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "no_entities": { "title": "Modbus {sub_1} contain no entities, entry not loaded.", From cbcd1929dd8e593e9fc647096aa5afae8df391e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 1 Apr 2025 05:37:59 -0400 Subject: [PATCH 021/217] Move Z-Wave JS smoke, CO, CO2, Heat, Water problem entities to diagnostic (#129922) * Move Z-Wave JS smoke, CO, CO2, Heat, Water problem entities to diagnostic * Update link + states * Specify problem class explicitly instead of catch-all * Heat alarm test is not a problem * Also split out smoke alarm * Document mapping rule * add tests * format * update test * review comments * remove idle state from doc as it is ignored --------- Co-authored-by: Petar Petrov --- .../components/zwave_js/binary_sensor.py | 99 +- tests/components/zwave_js/conftest.py | 20 + .../fixtures/zcombo_smoke_co_alarm_state.json | 854 ++++++++++++++++++ .../components/zwave_js/test_binary_sensor.py | 138 +++ 4 files changed, 1101 insertions(+), 10 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index d07846c8dcc..1439aa0ca0f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -67,7 +67,45 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): # Mappings for Notification sensors -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json +# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx +# +# Mapping rules: +# The catch all description should not have a device class and be marked as diagnostic. +# +# The following notifications have been moved to diagnostic: +# Smoke Alarm +# - Alarm silenced +# - Replacement required +# - Replacement required, End-of-life +# - Maintenance required, planned periodic inspection +# - Maintenance required, dust in device +# CO Alarm +# - Carbon monoxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# CO2 Alarm +# - Carbon dioxide test +# - Replacement required +# - Replacement required, End-of-life +# - Alarm silenced +# - Maintenance required, planned periodic inspection +# Heat Alarm +# - Rapid temperature rise (location provided) +# - Rapid temperature rise +# - Rapid temperature fall (location provided) +# - Rapid temperature fall +# - Heat alarm test +# - Alarm silenced +# - Replacement required, End-of-life +# - Maintenance required, dust in device +# - Maintenance required, planned periodic inspection + +# Water Alarm +# - Replace water filter +# - Sump pump failure + NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected @@ -75,10 +113,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.SMOKE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8 + key=NOTIFICATION_SMOKE_ALARM, + states=("4", "5", "7", "8"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - All other State Id's key=NOTIFICATION_SMOKE_ALARM, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 @@ -86,10 +131,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.CO, ), + NotificationZWaveJSEntityDescription( + # NotificationType 2: Carbon Monoxide - State Id 4, 5, 7 + key=NOTIFICATION_CARBON_MONOOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - All other State Id's key=NOTIFICATION_CARBON_MONOOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 @@ -97,10 +149,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = states=("1", "2"), device_class=BinarySensorDeviceClass.GAS, ), + NotificationZWaveJSEntityDescription( + # NotificationType 3: Carbon Dioxide - State Id's 4, 5, 7 + key=NOTIFICATION_CARBON_DIOXIDE, + states=("4", "5", "7"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - All other State Id's key=NOTIFICATION_CARBON_DIOXIDE, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) @@ -109,20 +168,34 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.HEAT, ), NotificationZWaveJSEntityDescription( - # NotificationType 4: Heat - All other State Id's + # NotificationType 4: Heat - State ID's 8, A, B key=NOTIFICATION_HEAT, + states=("8", "10", "11"), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( - # NotificationType 5: Water - State Id's 1, 2, 3, 4 + # NotificationType 4: Heat - All other State Id's + key=NOTIFICATION_HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A key=NOTIFICATION_WATER, - states=("1", "2", "3", "4"), + states=("1", "2", "3", "4", "6", "7", "8", "9", "10"), device_class=BinarySensorDeviceClass.MOISTURE, ), + NotificationZWaveJSEntityDescription( + # NotificationType 5: Water - State Id's B + key=NOTIFICATION_WATER, + states=("11",), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), NotificationZWaveJSEntityDescription( # NotificationType 5: Water - All other State Id's key=NOTIFICATION_WATER, - device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) @@ -214,16 +287,22 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = device_class=BinarySensorDeviceClass.SOUND, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id's 1, 2, 3, 4 key=NOTIFICATION_GAS, states=("1", "2", "3", "4"), device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( - # NotificationType 18: Gas + # NotificationType 18: Gas - State Id 6 key=NOTIFICATION_GAS, states=("6",), device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NotificationZWaveJSEntityDescription( + # NotificationType 18: Gas - All other State Id's + key=NOTIFICATION_GAS, + entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index ce7b0e0109e..e4e757ad363 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -509,6 +509,15 @@ def aeotec_smart_switch_7_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="zcombo_smoke_co_alarm_state") +def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: + """Load node with fixture data for ZCombo-G Smoke/CO Alarm.""" + return cast( + NodeDataType, + load_json_object_fixture("zcombo_smoke_co_alarm_state.json", DOMAIN), + ) + + # model fixtures @@ -554,6 +563,7 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) + client.disable_server_logging = MagicMock() client.driver = Driver( client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) ) @@ -1252,3 +1262,13 @@ def aeotec_smart_switch_7_fixture( node = Node(client, aeotec_smart_switch_7_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="zcombo_smoke_co_alarm") +def zcombo_smoke_co_alarm_fixture( + client: MagicMock, zcombo_smoke_co_alarm_state: NodeDataType +) -> Node: + """Load node for ZCombo-G Smoke/CO Alarm.""" + node = Node(client, zcombo_smoke_co_alarm_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json new file mode 100644 index 00000000000..c7417859f1c --- /dev/null +++ b/tests/components/zwave_js/fixtures/zcombo_smoke_co_alarm_state.json @@ -0,0 +1,854 @@ +{ + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 312, + "productId": 3, + "productType": 1, + "firmwareVersion": "11.0.0", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/data/db/devices/0x0138/zcombo-g.json", + "isEmbedded": true, + "manufacturer": "First Alert (BRK Brands Inc)", + "manufacturerId": 312, + "label": "ZCOMBO", + "description": "ZCombo-G Smoke/CO Alarm", + "devices": [ + { + "productType": 1, + "productId": 3 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "wakeup": "WAKEUP\n1. Slide battery door open and then closed with the batteries inserted.", + "inclusion": "ADD\n1. Slide battery door open.\n2. Insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "exclusion": "REMOVE\n1. Slide battery door open.\n2. Remove and re-insert batteries checking the correct orientation.\n3. Press and hold the test button. Keep it held down as you slide the battery drawer closed. You may then release the button.\nNOTE: Use only your finger or thumb on the test button. The use of any other instrument is strictly prohibited", + "reset": "RESET DEVICE\nIf the device is powered up with the test button held down for 10+ seconds, the device will reset all Z-Wave settings and leave the network.\nUpon completion of the Reset operation, the LED will glow and the horn will sound for ~1 second.\nPlease use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3886/User_Manual_M08-0456-173833_D2.pdf" + } + }, + "label": "ZCOMBO", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0138:0x0001:0x0003:11.0.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 4, + "commandsDroppedRX": 1, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -79, + "repeaterRSSI": [] + }, + "lastSeen": "2024-11-11T21:36:45.802Z", + "rtt": 28.9, + "rssi": -79 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-11-11T19:17:39.916Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Supervision Report Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "ZCOMBO will send the message over Supervision Command Class and it will wait for the Supervision report from the Controller for the Supervision report timeout time.", + "label": "Supervision Report Timeout", + "default": 1500, + "min": 500, + "max": 5000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1500 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Supervision Retry Count", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "If the Supervision report is not received within the Supervision report timeout time, the ZCOMBO will retry sending the message again. Upon exceeding the max retry, the ZCOMBO device will send the next message available in the queue.", + "label": "Supervision Retry Count", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Supervision Wait Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Before retrying the message, ZCOMBO will wait for the Supervision wait time. Actual wait time is calculated using the formula: Wait Time = Supervision wait time base-value + random-value + (attempt-count x 5 seconds). The random value will be between 100 and 1100 milliseconds.", + "label": "Supervision Wait Time", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Smoke detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 1 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Smoke alarm test", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Sensor status", + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Sensor status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Carbon monoxide detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Maintenance status", + "propertyName": "CO Alarm", + "propertyKeyName": "Maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maintenance status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "5": "Replacement required, End-of-life" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "CO Alarm", + "propertyKey": "Alarm status", + "propertyName": "CO Alarm", + "propertyKeyName": "Alarm status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm status", + "ccSpecific": { + "notificationType": 2 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Alarm silenced" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "System hardware failure" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 312 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 92 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 4200, + "readable": false, + "writeable": true, + "min": 4200, + "max": 4200, + "steps": 0, + "stateful": true, + "secret": false + }, + "value": 4200 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["11.0", "7.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "6.81.6" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "4.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 52445 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.7.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 97 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "11.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 0 + } + ], + "endpoints": [ + { + "nodeId": 3, + "index": 0, + "installerIcon": 3073, + "userIcon": 3073, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 657dd337bf9..93ac52f9041 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -293,3 +293,141 @@ async def test_config_parameter_binary_sensor( state = hass.states.get(binary_sensor_entity_id) assert state assert state.state == STATE_OFF + + +async def test_smoke_co_notification_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zcombo_smoke_co_alarm: Node, + integration: MockConfigEntry, +) -> None: + """Test smoke and CO notification sensors with diagnostic states.""" + # Test smoke alarm sensor + smoke_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_detected" + state = hass.states.get(smoke_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.SMOKE + entity_entry = entity_registry.async_get(smoke_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test smoke alarm diagnostic sensor + smoke_diagnostic = "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test" + state = hass.states.get(smoke_diagnostic) + assert state + assert state.state == STATE_OFF + entity_entry = entity_registry.async_get(smoke_diagnostic) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test CO alarm sensor + co_sensor = "binary_sensor.zcombo_g_smoke_co_alarm_carbon_monoxide_detected" + state = hass.states.get(co_sensor) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CO + entity_entry = entity_registry.async_get(co_sensor) + assert entity_entry + assert entity_entry.entity_category != EntityCategory.DIAGNOSTIC + + # Test diagnostic entities + entity_ids = [ + "binary_sensor.zcombo_g_smoke_co_alarm_smoke_alarm_test", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced", + "binary_sensor.zcombo_g_smoke_co_alarm_replacement_required_end_of_life", + "binary_sensor.zcombo_g_smoke_co_alarm_alarm_silenced_2", + "binary_sensor.zcombo_g_smoke_co_alarm_system_hardware_failure", + "binary_sensor.zcombo_g_smoke_co_alarm_low_battery_level", + ] + for entity_id in entity_ids: + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + # Test state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_sensor) + assert state is not None, "Smoke sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke sensor state to be 'on', got '{state.state}'" + ) + + # Test state updates for CO alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "CO Alarm", + "propertyKey": "Sensor status", + "newValue": 2, + "prevValue": 0, + "propertyName": "CO Alarm", + "propertyKeyName": "Sensor status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(co_sensor) + assert state is not None, "CO sensor state should not be None" + assert state.state == STATE_ON, ( + f"Expected CO sensor state to be 'on', got '{state.state}'" + ) + + # Test diagnostic state updates for smoke alarm + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 3, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Smoke Alarm", + "propertyKey": "Alarm status", + "newValue": 3, + "prevValue": 0, + "propertyName": "Smoke Alarm", + "propertyKeyName": "Alarm status", + }, + }, + ) + zcombo_smoke_co_alarm.receive_event(event) + await hass.async_block_till_done() # Wait for state change to be processed + # Get a fresh state after the sleep + state = hass.states.get(smoke_diagnostic) + assert state is not None, "Smoke diagnostic state should not be None" + assert state.state == STATE_ON, ( + f"Expected smoke diagnostic state to be 'on', got '{state.state}'" + ) From c15169635748b83d29718ccc4879f8f7f5060d3a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 11:38:48 +0200 Subject: [PATCH 022/217] Fix spelling in Reolink user-facing strings (#141971) Fix spelling in `reolink` user-facing string - replace three occurrences of "a" with proper "an" - replace "infra red" with "infrared" --- homeassistant/components/reolink/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index a884b3ed431..8bfea1c6910 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -15,7 +15,7 @@ "data_description": { "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", - "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "use_https": "Use an HTTPS (SSL) connection to the Reolink device.", "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." @@ -66,7 +66,7 @@ "message": "Invalid input parameter: {err}" }, "api_error": { - "message": "The device responded with a error: {err}" + "message": "The device responded with an error: {err}" }, "invalid_content_type": { "message": "Received a different content type than expected: {err}" @@ -130,7 +130,7 @@ }, "firmware_update": { "title": "Reolink firmware update required", - "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running an old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})." }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", @@ -893,7 +893,7 @@ }, "switch": { "ir_lights": { - "name": "Infra red lights in night mode" + "name": "Infrared lights in night mode" }, "record_audio": { "name": "Record audio" From 145e02769c7d58c9838178e4448a748c2e616b56 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 12:54:24 +0200 Subject: [PATCH 023/217] Remove redundant type hint from core_config.py (#141989) --- homeassistant/core_config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f080705fced..9cd232097a7 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -581,9 +581,7 @@ class Config: self.all_components: set[str] = set() # Set of loaded components - self.components: _ComponentSet = _ComponentSet( - self.top_level_components, self.all_components - ) + self.components = _ComponentSet(self.top_level_components, self.all_components) # API (HTTP) server configuration self.api: ApiConfig | None = None From fa9613a879482158300eeaa372e96cd2a619dbf1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 14:24:15 +0200 Subject: [PATCH 024/217] Unconditionally import turbojpeg from camera (#141995) --- homeassistant/components/camera/img_util.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index bbe85bf82db..971e6804add 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -2,17 +2,10 @@ from __future__ import annotations -from contextlib import suppress import logging from typing import TYPE_CHECKING, Literal, cast -with suppress(Exception): - # TurboJPEG imports numpy which may or may not work so - # we have to guard the import here. We still want - # to import it at top level so it gets loaded - # in the import executor and not in the event loop. - from turbojpeg import TurboJPEG - +from turbojpeg import TurboJPEG if TYPE_CHECKING: from . import Image From 2427b77363fa3119d5441546b3505b2436d133f8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 14:31:49 +0200 Subject: [PATCH 025/217] Use send_json_auto_id in websocket_api tests (#141994) --- .../components/websocket_api/test_commands.py | 338 +++++++----------- 1 file changed, 123 insertions(+), 215 deletions(-) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c0114cde42b..f03673048c0 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -106,9 +106,8 @@ async def test_fire_event( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", "event_data": {"hello": "world"}, @@ -116,7 +115,6 @@ async def test_fire_event( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -137,16 +135,14 @@ async def test_fire_event_without_data( hass.bus.async_listen_once("event_type_test", event_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "fire_event", "event_type": "event_type_test", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -162,9 +158,8 @@ async def test_call_service( """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -173,7 +168,6 @@ async def test_call_service( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -191,9 +185,8 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N hass.services.async_register( "domain_test", "test_service_with_no_response", lambda x: None ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 8, "type": "call_service", "domain": "domain_test", "service": "test_service_with_no_response", @@ -203,7 +196,6 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N ) msg = await websocket_client.receive_json() - assert msg["id"] == 8 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "service_validation_error" @@ -225,9 +217,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = {"foo": "bar"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 4, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -237,7 +228,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 4 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["response"] == {"foo": "bar"} @@ -256,9 +246,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -267,7 +256,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -286,9 +274,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "homeassistant", "service": "test_service", @@ -296,7 +283,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -315,9 +301,8 @@ async def test_call_service_blocking( "homeassistant.core.ServiceRegistry.async_call", autospec=True ) as mock_call: mock_call.return_value = None - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "homeassistant", "service": "restart", @@ -325,7 +310,6 @@ async def test_call_service_blocking( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] mock_call.assert_called_once_with( @@ -346,9 +330,8 @@ async def test_call_service_target( """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -361,7 +344,6 @@ async def test_call_service_target( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -382,9 +364,8 @@ async def test_call_service_target_template( hass: HomeAssistant, websocket_client ) -> None: """Test call service command with target does not allow template.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -396,7 +377,6 @@ async def test_call_service_target_template( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -406,9 +386,8 @@ async def test_call_service_not_found( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test call service command.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -417,7 +396,6 @@ async def test_call_service_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_NOT_FOUND @@ -440,9 +418,8 @@ async def test_call_service_child_not_found( hass.services.async_register("domain_test", "test_service", serv_handler) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -451,7 +428,6 @@ async def test_call_service_child_not_found( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_HOME_ASSISTANT_ERROR @@ -492,9 +468,8 @@ async def test_call_service_schema_validation_error( schema=service_schema, ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -502,14 +477,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -517,14 +490,12 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "test_service", @@ -532,7 +503,6 @@ async def test_call_service_schema_validation_error( } ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT @@ -573,9 +543,8 @@ async def test_call_service_error( hass.services.async_register("domain_test", "unknown_error", unknown_error_call) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "call_service", "domain": "domain_test", "service": "ha_error", @@ -583,7 +552,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "home_assistant_error" @@ -592,9 +560,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "call_service", "domain": "domain_test", "service": "service_error", @@ -602,7 +569,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "service_validation_error" @@ -611,9 +577,8 @@ async def test_call_service_error( assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "call_service", "domain": "domain_test", "service": "unknown_error", @@ -621,7 +586,6 @@ async def test_call_service_error( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" @@ -634,12 +598,12 @@ async def test_subscribe_unsubscribe_events( """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -653,7 +617,7 @@ async def test_subscribe_unsubscribe_events( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] @@ -661,12 +625,11 @@ async def test_subscribe_unsubscribe_events( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": subscription} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -681,10 +644,9 @@ async def test_get_states( hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -711,10 +673,9 @@ async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test get_config command.""" - await websocket_client.send_json({"id": 5, "type": "get_config"}) + await websocket_client.send_json_auto_id({"type": "get_config"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -737,10 +698,9 @@ async def test_get_config( async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" - await websocket_client.send_json({"id": 5, "type": "ping"}) + await websocket_client.send_json_auto_id({"type": "ping"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "pong" @@ -792,8 +752,8 @@ async def test_subscribe_requires_admin( ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "test_event"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "test_event"} ) msg = await websocket_client.receive_json() @@ -809,10 +769,9 @@ async def test_states_filters_visible( hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -828,13 +787,12 @@ async def test_get_states_not_allows_nan( hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") - await websocket_client.send_json({"id": 5, "type": "get_states"}) + await websocket_client.send_json_auto_id({"type": "get_states"}) bad = dict(hass.states.get("greeting.bad").as_dict()) bad["attributes"] = dict(bad["attributes"]) bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -852,22 +810,21 @@ async def test_subscribe_unsubscribe_events_whitelist( """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] - await websocket_client.send_json( - {"id": 5, "type": "subscribe_events", "event_type": "not-in-whitelist"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "not-in-whitelist"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "unauthorized" - await websocket_client.send_json( - {"id": 6, "type": "subscribe_events", "event_type": "themes_updated"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "themes_updated"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 + themes_updated_subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -876,7 +833,7 @@ async def test_subscribe_unsubscribe_events_whitelist( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 6 + assert msg["id"] == themes_updated_subscription assert msg["type"] == "event" event = msg["event"] assert event["event_type"] == "themes_updated" @@ -892,12 +849,12 @@ async def test_subscribe_unsubscribe_events_state_changed( hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_events", "event_type": "state_changed"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_events", "event_type": "state_changed"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -905,7 +862,7 @@ async def test_subscribe_unsubscribe_events_state_changed( hass.states.async_set("light.permitted", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"]["event_type"] == "state_changed" assert msg["event"]["data"]["entity_id"] == "light.permitted" @@ -949,15 +906,15 @@ async def test_subscribe_entities_with_unserializable_state( } ) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -971,7 +928,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.permitted", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -988,7 +945,7 @@ async def test_subscribe_entities_with_unserializable_state( } hass.states.async_set("light.cannot_serialize", "on", {"effect": "help"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" # Order does not matter msg["event"]["c"]["light.cannot_serialize"]["-"]["a"] = set( @@ -1022,7 +979,7 @@ async def test_subscribe_entities_with_unserializable_state( {"color": "red", "cannot_serialize": CannotSerializeMe()}, ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "result" assert msg["error"] == { "code": "unknown_error", @@ -1052,15 +1009,15 @@ async def test_subscribe_unsubscribe_entities( hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) assert not hass_admin_user.is_admin - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1083,7 +1040,7 @@ async def test_subscribe_unsubscribe_entities( hass.states.async_set("light.permitted", "on", {"effect": "help", "color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1115,7 +1072,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1148,7 +1105,7 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1180,12 +1137,12 @@ async def test_subscribe_unsubscribe_entities( } msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"r": ["light.permitted"]} msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1219,17 +1176,17 @@ async def test_subscribe_unsubscribe_entities_specific_entities( } ) - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "entity_ids": ["light.permitted"]} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "entity_ids": ["light.permitted"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert isinstance(msg["event"]["a"]["light.permitted"]["c"], str) assert msg["event"] == { @@ -1247,7 +1204,7 @@ async def test_subscribe_unsubscribe_entities_specific_entities( hass.states.async_set("light.permitted", "on", {"color": "blue"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1271,17 +1228,17 @@ async def test_subscribe_unsubscribe_entities_with_filter( """Test subscribe/unsubscribe entities with an entity filter.""" hass.states.async_set("switch.not_included", "off") hass.states.async_set("light.include", "off") - await websocket_client.send_json( - {"id": 7, "type": "subscribe_entities", "include": {"domains": ["light"]}} + await websocket_client.send_json_auto_id( + {"type": "subscribe_entities", "include": {"domains": ["light"]}} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": { @@ -1296,7 +1253,7 @@ async def test_subscribe_unsubscribe_entities_with_filter( hass.states.async_set("switch.not_included", "on") hass.states.async_set("light.include", "on") msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": { @@ -1317,21 +1274,20 @@ async def test_render_template_renders_template( """Test simple template is rendered and updated.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1346,7 +1302,7 @@ async def test_render_template_renders_template( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1364,9 +1320,8 @@ async def test_render_template_with_timeout_and_variables( hass: HomeAssistant, websocket_client ) -> None: """Test a template with a timeout and variables renders without error.""" - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 10, "variables": {"test": {"value": "hello"}}, @@ -1375,12 +1330,12 @@ async def test_render_template_with_timeout_and_variables( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1400,21 +1355,20 @@ async def test_render_template_manual_entity_ids_no_longer_needed( """Test that updates to specified entity ids cause a template rerender.""" hass.states.async_set("light.test", "on") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": "State is: {{ states('light.test') }}", } ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1429,7 +1383,7 @@ async def test_render_template_manual_entity_ids_no_longer_needed( hass.states.async_set("light.test", "off") msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1523,9 +1477,8 @@ async def test_render_template_with_error( ) -> None: """Test a template with an error.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1534,7 +1487,6 @@ async def test_render_template_with_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1596,9 +1548,8 @@ async def test_render_template_with_timeout_and_error( ) -> None: """Test a template with an error with a timeout.""" caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1608,7 +1559,6 @@ async def test_render_template_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1666,9 +1616,8 @@ async def test_render_template_strict_with_timeout_and_error( In this test report_errors is enabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1679,7 +1628,6 @@ async def test_render_template_strict_with_timeout_and_error( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1729,9 +1677,8 @@ async def test_render_template_strict_with_timeout_and_error_2( In this test report_errors is disabled. """ caplog.set_level(logging.INFO) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "timeout": 5, @@ -1741,7 +1688,6 @@ async def test_render_template_strict_with_timeout_and_error_2( for expected_event in expected_events: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1815,9 +1761,8 @@ async def test_render_template_error_in_template_code( In this test report_errors is enabled. """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template, "report_errors": True, @@ -1826,7 +1771,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1834,7 +1778,6 @@ async def test_render_template_error_in_template_code( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1882,13 +1825,12 @@ async def test_render_template_error_in_template_code_2( In this test report_errors is disabled. """ - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": template} ) for expected_event in expected_events_1: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1896,7 +1838,6 @@ async def test_render_template_error_in_template_code_2( for expected_event in expected_events_2: msg = await websocket_client.receive_json() - assert msg["id"] == 5 for key, value in expected_event.items(): assert msg[key] == value @@ -1924,9 +1865,8 @@ async def test_render_template_with_delayed_error( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": True, @@ -1935,7 +1875,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1943,7 +1883,7 @@ async def test_render_template_with_delayed_error( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1957,13 +1897,13 @@ async def test_render_template_with_delayed_error( } msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event["error"] == "'None' has no attribute 'state'" msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -1994,9 +1934,8 @@ async def test_render_template_with_delayed_error_2( {% endif %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "template": template_str, "report_errors": False, @@ -2005,7 +1944,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2013,7 +1952,7 @@ async def test_render_template_with_delayed_error_2( await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 + assert msg["id"] == subscription assert msg["type"] == "event" event = msg["event"] assert event == { @@ -2044,9 +1983,8 @@ async def test_render_template_with_timeout( {%- endfor %} """ - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "render_template", "timeout": 0.000001, "template": slow_template_str, @@ -2054,7 +1992,6 @@ async def test_render_template_with_timeout( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR @@ -2066,12 +2003,11 @@ async def test_render_template_returns_with_match_all( hass: HomeAssistant, websocket_client ) -> None: """Test that a template that would match with all entities still return success.""" - await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "State is: {{ 42 }}"} + await websocket_client.send_json_auto_id( + {"type": "render_template", "template": "State is: {{ 42 }}"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2083,10 +2019,9 @@ async def test_manifest_list( http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json({"id": 5, "type": "manifest/list"}) + await websocket_client.send_json_auto_id({"type": "manifest/list"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2101,13 +2036,12 @@ async def test_manifest_list_specific_integrations( """Test loading manifests for specific integrations.""" websocket_api = await async_get_integration(hass, "websocket_api") - await websocket_client.send_json( - {"id": 5, "type": "manifest/list", "integrations": ["hue", "websocket_api"]} + await websocket_client.send_json_auto_id( + {"type": "manifest/list", "integrations": ["hue", "websocket_api"]} ) hue = await async_get_integration(hass, "hue") msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert sorted(msg["result"], key=lambda manifest: manifest["domain"]) == [ @@ -2122,23 +2056,21 @@ async def test_manifest_get( """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") - await websocket_client.send_json( - {"id": 6, "type": "manifest/get", "integration": "hue"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "hue"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == hue.manifest # Non existing - await websocket_client.send_json( - {"id": 7, "type": "manifest/get", "integration": "non_existing"} + await websocket_client.send_json_auto_id( + {"type": "manifest/get", "integration": "non_existing"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == "not_found" @@ -2157,10 +2089,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 6, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2175,10 +2106,9 @@ async def test_entity_source_admin( ) # Fetch all - await websocket_client.send_json({"id": 10, "type": "entity/source"}) + await websocket_client.send_json_auto_id({"type": "entity/source"}) msg = await websocket_client.receive_json() - assert msg["id"] == 10 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { @@ -2192,9 +2122,8 @@ async def test_subscribe_trigger( """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "subscribe_trigger", "trigger": {"platform": "event", "event_type": "test_event"}, "variables": {"hello": "world"}, @@ -2202,7 +2131,6 @@ async def test_subscribe_trigger( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2218,7 +2146,6 @@ async def test_subscribe_trigger( async with asyncio.timeout(3): msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == "event" assert msg["event"]["context"]["id"] == context.id assert msg["event"]["variables"]["trigger"]["platform"] == "event" @@ -2229,12 +2156,11 @@ async def test_subscribe_trigger( assert event["data"] == {"hello": "world"} assert event["origin"] == "LOCAL" - await websocket_client.send_json( - {"id": 6, "type": "unsubscribe_events", "subscription": 5} + await websocket_client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": msg["id"]} ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2248,9 +2174,8 @@ async def test_test_condition( """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "test_condition", "condition": { "condition": "state", @@ -2262,14 +2187,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "test_condition", "condition": { "condition": "template", @@ -2280,14 +2203,12 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 6 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is True - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 7, "type": "test_condition", "condition": { "condition": "template", @@ -2298,7 +2219,6 @@ async def test_test_condition( ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"]["result"] is False @@ -2312,9 +2232,8 @@ async def test_execute_script( hass, "domain_test", "test_service", response={"hello": "world"} ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2328,14 +2247,12 @@ async def test_execute_script( ) msg_no_var = await websocket_client.receive_json() - assert msg_no_var["id"] == 5 assert msg_no_var["type"] == const.TYPE_RESULT assert msg_no_var["success"] assert msg_no_var["result"]["response"] == {"hello": "world"} - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 6, "type": "execute_script", "sequence": { "service": "domain_test.test_service", @@ -2346,7 +2263,6 @@ async def test_execute_script( ) msg_var = await websocket_client.receive_json() - assert msg_var["id"] == 6 assert msg_var["type"] == const.TYPE_RESULT assert msg_var["success"] @@ -2403,9 +2319,8 @@ async def test_execute_script_err_localization( hass, "domain_test", "test_service", raise_exception=raise_exception ) - await websocket_client.send_json( + await websocket_client.send_json_auto_id( { - "id": 5, "type": "execute_script", "sequence": [ { @@ -2418,7 +2333,6 @@ async def test_execute_script_err_localization( ) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] is False assert msg["error"]["code"] == err_code @@ -2522,12 +2436,12 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" - await websocket_client.send_json( - {"id": 7, "type": "subscribe_bootstrap_integrations"} + await websocket_client.send_json_auto_id( + {"type": "subscribe_bootstrap_integrations"} ) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -2535,7 +2449,7 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, message) msg = await websocket_client.receive_json() - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == message @@ -2553,10 +2467,9 @@ async def test_integration_setup_info( "isy994": 12.8, }, ): - await websocket_client.send_json({"id": 7, "type": "integration/setup_info"}) + await websocket_client.send_json_auto_id({"type": "integration/setup_info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [ @@ -2855,12 +2768,7 @@ async def test_integration_descriptions( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 1, - "type": "integration/descriptions", - } - ) + await ws_client.send_json_auto_id({"type": "integration/descriptions"}) response = await ws_client.receive_json() assert response["success"] @@ -2884,31 +2792,31 @@ async def test_subscribe_entities_chained_state_change( async_track_state_change_event(hass, ["light.permitted"], auto_off_listener) - await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) + await websocket_client.send_json_auto_id({"type": "subscribe_entities"}) data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + subscription = msg["id"] assert msg["type"] == const.TYPE_RESULT assert msg["success"] data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == {"a": {}} hass.states.async_set("light.permitted", "on") data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "a": {"light.permitted": {"a": {}, "c": ANY, "lc": ANY, "s": "on"}} } data = await websocket_client.receive_str() msg = json_loads(data) - assert msg["id"] == 7 + assert msg["id"] == subscription assert msg["type"] == "event" assert msg["event"] == { "c": {"light.permitted": {"+": {"c": ANY, "lc": ANY, "s": "off"}}} From 50c12d44870d0394e5022567735f87ac7f0272c1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 1 Apr 2025 14:39:44 +0200 Subject: [PATCH 026/217] Move Vodafone Station to platinum quality scale (#141406) --- homeassistant/components/vodafone_station/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 29cb3c070ab..a36af1466d6 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiovodafone==0.6.1"] } From 32ee31b8c7a98045096f97835d1ae08c623257f7 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Tue, 1 Apr 2025 14:41:24 +0200 Subject: [PATCH 027/217] Use saved volume when selecting preset in bluesound integration (#141079) * Use load_preset to select preset as source * Add tests * Fix --------- Co-authored-by: Joostlek --- .../components/bluesound/media_player.py | 12 ++++---- tests/components/bluesound/conftest.py | 4 +-- .../components/bluesound/test_media_player.py | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 135d1b5d27e..0addcc1daac 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity return # presets and inputs might have the same name; presets have priority - url: str | None = None for input_ in self._inputs: if input_.text == source: - url = input_.url + await self._player.play_url(input_.url) + return for preset in self._presets: if preset.name == source: - url = preset.url + await self._player.load_preset(preset.id) + return - if url is None: - raise ServiceValidationError(f"Source {source} not found") - - await self._player.play_url(url) + raise ServiceValidationError(f"Source {source} not found") async def async_clear_playlist(self) -> None: """Clear players playlist.""" diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py index 717c9f61850..63597ed0532 100644 --- a/tests/components/bluesound/conftest.py +++ b/tests/components/bluesound/conftest.py @@ -102,8 +102,8 @@ class PlayerMockData: ) player.presets = AsyncMock( return_value=[ - Preset("preset1", "1", "url1", "image1", None), - Preset("preset2", "2", "url2", "image2", None), + Preset("preset1", 1, "url1", "image1", None), + Preset("preset2", 2, "url2", "image2", None), ] ) diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index ed537d0bc57..dcff33399f5 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -17,12 +17,14 @@ from homeassistant.components.bluesound.media_player import ( SERVICE_SET_TIMER, ) from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_SELECT_SOURCE, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, @@ -119,6 +121,32 @@ async def test_volume_down( player_mocks.player_data.player.volume.assert_called_once_with(level=9) +async def test_select_input_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select input source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "input1"}, + ) + + player_mocks.player_data.player.play_url.assert_called_once_with("url1") + + +async def test_select_preset_source( + hass: HomeAssistant, setup_config_entry: None, player_mocks: PlayerMocks +) -> None: + """Test the media player select preset source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.player_name1111", ATTR_INPUT_SOURCE: "preset1"}, + ) + + player_mocks.player_data.player.load_preset.assert_called_once_with(1) + + async def test_attributes_set( hass: HomeAssistant, setup_config_entry: None, From 7068986c14ed383de57a17b4be50abbaebca23e6 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 1 Apr 2025 14:48:45 +0100 Subject: [PATCH 028/217] Bump Ohme to platinum (#141762) * Bump version of ohmepy and fix types * Add strict typing to ohme * Inject websession for ohme * CI/code formatting fixes for ohme * Update mypy.ini for ohme * Fix typing in services for ohme * Bump ohme quality in manifest --- .strict-typing | 1 + homeassistant/components/ohme/__init__.py | 7 ++++++- homeassistant/components/ohme/manifest.json | 2 +- homeassistant/components/ohme/quality_scale.yaml | 6 +++--- homeassistant/components/ohme/services.py | 5 +++-- mypy.ini | 10 ++++++++++ 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index e0c4e569f4b..3e8ad0ddbaf 100644 --- a/.strict-typing +++ b/.strict-typing @@ -364,6 +364,7 @@ homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* homeassistant.components.nut.* +homeassistant.components.ohme.* homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onedrive.* diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index e3e252cbf8b..c304bfdf72d 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS @@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool: """Set up Ohme from a config entry.""" - client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) + client = OhmeApiClient( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) try: await client.async_login() diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 30a55360ce2..786c615d68a 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ohme/", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["ohme==1.5.1"] } diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index 12473a08edd..2f7aece5bb6 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -75,6 +75,6 @@ rules: comment: | Not supported by the API. Accounts and devices have a one-to-one relationship. # Platinum - async-dependency: todo - inject-websession: todo - strict-typing: todo + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index 249fb1abdab..8ed29aa373d 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -5,7 +5,7 @@ from typing import Final from ohme import OhmeApiClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import selector from .const import DOMAIN +from .coordinator import OhmeConfigEntry ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_PRICE_CAP: Final = "price_cap" @@ -47,7 +48,7 @@ SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema( def __get_client(call: ServiceCall) -> OhmeApiClient: """Get the client from the config entry.""" entry_id: str = call.data[ATTR_CONFIG_ENTRY] - entry: ConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) + entry: OhmeConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id) if not entry: raise ServiceValidationError( diff --git a/mypy.ini b/mypy.ini index 9831a183ec4..685412e6e98 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3396,6 +3396,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ohme.*] +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.onboarding.*] check_untyped_defs = true disallow_incomplete_defs = true From aaafdee56fae5d5b1e5536322a45f13440ffa68f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 1 Apr 2025 16:05:46 +0200 Subject: [PATCH 029/217] Remove un-necessary wait for background tasks in Comelit tests (#142000) --- tests/components/comelit/test_climate.py | 4 ++-- tests/components/comelit/test_coordinator.py | 2 +- tests/components/comelit/test_sensor.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py index 44478d154f4..f9f28b4d675 100644 --- a/tests/components/comelit/test_climate.py +++ b/tests/components/comelit/test_climate.py @@ -112,7 +112,7 @@ async def test_climate_data_update( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(ENTITY_ID)) assert state.state == mode @@ -149,7 +149,7 @@ async def test_climate_data_update_bad_data( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.HEAT diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py index a8ef82a7e89..49e3164e875 100644 --- a/tests/components/comelit/test_coordinator.py +++ b/tests/components/comelit/test_coordinator.py @@ -43,7 +43,7 @@ async def test_coordinator_data_update_fails( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index 56409083165..8473158f662 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -84,7 +84,7 @@ async def test_sensor_state_unknown( freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done() assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNKNOWN From 78338f161f8dbfa2bde3bd10fed76f5539fdc8ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 16:13:18 +0200 Subject: [PATCH 030/217] Add base class for onboarding views (#141970) --- homeassistant/components/onboarding/views.py | 79 +++++++++----------- 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index f0638e72d94..52f63d91770 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -51,7 +51,7 @@ async def async_setup( hass: HomeAssistant, data: OnboardingStoreData, store: OnboardingStorage ) -> None: """Set up the onboarding view.""" - hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(OnboardingStatusView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) hass.http.register_view(UserOnboardingView(data, store)) hass.http.register_view(CoreConfigOnboardingView(data, store)) @@ -63,17 +63,30 @@ async def async_setup( setup_cloud_views(hass, data) -class OnboardingView(HomeAssistantView): - """Return the onboarding status.""" +class _BaseOnboardingView(HomeAssistantView): + """Base class for onboarding views.""" + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the onboarding view.""" + self._data = data + + +class _NoAuthBaseOnboardingView(_BaseOnboardingView): + """Base class for unauthenticated onboarding views.""" requires_auth = False + + +class OnboardingStatusView(_NoAuthBaseOnboardingView): + """Return the onboarding status.""" + url = "/api/onboarding" name = "api:onboarding" def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" @@ -82,17 +95,12 @@ class OnboardingView(HomeAssistantView): ) -class InstallationTypeOnboardingView(HomeAssistantView): +class InstallationTypeOnboardingView(_NoAuthBaseOnboardingView): """Return the installation type during onboarding.""" - requires_auth = False url = "/api/onboarding/installation_type" name = "api:onboarding:installation_type" - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the onboarding installation type view.""" - self._data = data - async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" if self._data["done"]: @@ -103,15 +111,15 @@ class InstallationTypeOnboardingView(HomeAssistantView): return self.json({"installation_type": info["installation_type"]}) -class _BaseOnboardingView(HomeAssistantView): - """Base class for onboarding.""" +class _BaseOnboardingStepView(_BaseOnboardingView): + """Base class for an onboarding step.""" step: str def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None: """Initialize the onboarding view.""" + super().__init__(data) self._store = store - self._data = data self._lock = asyncio.Lock() @callback @@ -131,7 +139,7 @@ class _BaseOnboardingView(HomeAssistantView): listener() -class UserOnboardingView(_BaseOnboardingView): +class UserOnboardingView(_BaseOnboardingStepView): """View to handle create user onboarding step.""" url = "/api/onboarding/users" @@ -197,7 +205,7 @@ class UserOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class CoreConfigOnboardingView(_BaseOnboardingView): +class CoreConfigOnboardingView(_BaseOnboardingStepView): """View to finish core config onboarding step.""" url = "/api/onboarding/core_config" @@ -243,7 +251,7 @@ class CoreConfigOnboardingView(_BaseOnboardingView): return self.json({}) -class IntegrationOnboardingView(_BaseOnboardingView): +class IntegrationOnboardingView(_BaseOnboardingStepView): """View to finish integration onboarding step.""" url = "/api/onboarding/integration" @@ -290,7 +298,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): return self.json({"auth_code": auth_code}) -class AnalyticsOnboardingView(_BaseOnboardingView): +class AnalyticsOnboardingView(_BaseOnboardingStepView): """View to finish analytics onboarding step.""" url = "/api/onboarding/analytics" @@ -312,17 +320,7 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) -class BackupOnboardingView(HomeAssistantView): - """Backup onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - -def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( +def with_backup_manager[_ViewT: _BaseOnboardingView, **_P]( func: Callable[ Concatenate[_ViewT, BackupManager, web.Request, _P], Coroutine[Any, Any, web.Response], @@ -354,7 +352,7 @@ def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( return with_backup -class BackupInfoView(BackupOnboardingView): +class BackupInfoView(_NoAuthBaseOnboardingView): """Get backup info view.""" url = "/api/onboarding/backup/info" @@ -373,7 +371,7 @@ class BackupInfoView(BackupOnboardingView): ) -class RestoreBackupView(BackupOnboardingView): +class RestoreBackupView(_NoAuthBaseOnboardingView): """Restore backup view.""" url = "/api/onboarding/backup/restore" @@ -418,7 +416,7 @@ class RestoreBackupView(BackupOnboardingView): return web.Response(status=HTTPStatus.OK) -class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): +class UploadBackupView(_NoAuthBaseOnboardingView, backup_http.UploadBackupView): """Upload backup view.""" url = "/api/onboarding/backup/upload" @@ -442,16 +440,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: # pylint: disable-next=import-outside-toplevel,hass-component-root-import from homeassistant.components.cloud.const import DATA_CLOUD - class CloudOnboardingView(HomeAssistantView): - """Cloud onboarding view.""" - - requires_auth = False - - def __init__(self, data: OnboardingStoreData) -> None: - """Initialize the view.""" - self._data = data - - def with_cloud[_ViewT: CloudOnboardingView, **_P]( + def with_cloud[_ViewT: _BaseOnboardingView, **_P]( func: Callable[ Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response], @@ -486,7 +475,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: return _with_cloud class CloudForgotPasswordView( - CloudOnboardingView, cloud_http.CloudForgotPasswordView + _NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView ): """View to start Forgot Password flow.""" @@ -498,7 +487,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Handle forgot password request.""" return await super()._post(request) - class CloudLoginView(CloudOnboardingView, cloud_http.CloudLoginView): + class CloudLoginView(_NoAuthBaseOnboardingView, cloud_http.CloudLoginView): """Login to Home Assistant Cloud.""" url = "/api/onboarding/cloud/login" @@ -509,7 +498,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Handle login request.""" return await super()._post(request) - class CloudLogoutView(CloudOnboardingView, cloud_http.CloudLogoutView): + class CloudLogoutView(_NoAuthBaseOnboardingView, cloud_http.CloudLogoutView): """Log out of the Home Assistant cloud.""" url = "/api/onboarding/cloud/logout" @@ -520,7 +509,7 @@ def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Handle logout request.""" return await super()._post(request) - class CloudStatusView(CloudOnboardingView): + class CloudStatusView(_NoAuthBaseOnboardingView): """Get cloud status view.""" url = "/api/onboarding/cloud/status" From c4f0d9d2fa4c1b63a9ce2c0170b46273516d611e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 1 Apr 2025 16:28:29 +0200 Subject: [PATCH 031/217] Always set up after dependencies if they are scheduled to be loaded (#141593) * Always setup after dependencies * Add comment --- homeassistant/setup.py | 23 +++++++++++------------ tests/test_setup.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 334e3a9e074..7f037482f0d 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -213,17 +213,24 @@ async def _async_process_dependencies( if dep not in hass.config.components } - after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {} to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) + # We don't want to just wait for the futures from `to_be_loaded` here. + # We want to ensure that our after_dependencies are always actually + # scheduled to be set up, as if for whatever reason they had not been, + # we would deadlock waiting for them here. for dep in integration.after_dependencies: if ( dep not in dependencies_tasks and dep in to_be_loaded and dep not in hass.config.components ): - after_dependencies_tasks[dep] = to_be_loaded[dep] + dependencies_tasks[dep] = setup_futures.get(dep) or create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as after dependency of {integration.domain}", + loop=hass.loop, + ) - if not dependencies_tasks and not after_dependencies_tasks: + if not dependencies_tasks: return [] if dependencies_tasks: @@ -232,17 +239,9 @@ async def _async_process_dependencies( integration.domain, dependencies_tasks.keys(), ) - if after_dependencies_tasks: - _LOGGER.debug( - "Dependency %s will wait for after dependencies %s", - integration.domain, - after_dependencies_tasks.keys(), - ) async with hass.timeout.async_freeze(integration.domain): - results = await asyncio.gather( - *dependencies_tasks.values(), *after_dependencies_tasks.values() - ) + results = await asyncio.gather(*dependencies_tasks.values()) failed = [ domain for idx, domain in enumerate(dependencies_tasks) if not results[idx] diff --git a/tests/test_setup.py b/tests/test_setup.py index bb221c7cb4c..1f0e668d4e2 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -458,6 +458,29 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: assert not hass.data[setup.DATA_SETUP_DONE] +async def test_component_setup_after_dependencies(hass: HomeAssistant) -> None: + """Test that after dependencies are set up before the component.""" + mock_integration(hass, MockModule("dep")) + mock_integration( + hass, MockModule("comp", partial_manifest={"after_dependencies": ["dep"]}) + ) + mock_integration( + hass, MockModule("comp2", partial_manifest={"after_dependencies": ["dep"]}) + ) + + setup.async_set_domains_to_be_loaded(hass, {"comp"}) + + assert await setup.async_setup_component(hass, "comp", {}) + assert "comp" in hass.config.components + assert "dep" not in hass.config.components + + setup.async_set_domains_to_be_loaded(hass, {"comp2", "dep"}) + + assert await setup.async_setup_component(hass, "comp2", {}) + assert "comp2" in hass.config.components + assert "dep" in hass.config.components + + async def test_component_setup_with_validation_and_dependency( hass: HomeAssistant, ) -> None: From b9a0d553abecf870ee18887726a824dff771430c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Apr 2025 16:29:18 +0200 Subject: [PATCH 032/217] Fix import issues related to onboarding views (#141919) * Fix import issues related to onboarding views * Add ha-intents and numpy to pyproject.toml * Add more requirements to pyproject.toml * Add more requirements to pyproject.toml --- homeassistant/components/onboarding/views.py | 16 ++++++-- homeassistant/package_constraints.txt | 1 + pyproject.toml | 40 ++++++++++++++++++++ requirements.txt | 8 ++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 52f63d91770..978e16963d9 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -31,7 +31,7 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations -from homeassistant.setup import async_setup_component +from homeassistant.setup import SetupPhases, async_pause_setup, async_setup_component if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -60,7 +60,7 @@ async def async_setup( hass.http.register_view(BackupInfoView(data)) hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) - setup_cloud_views(hass, data) + await setup_cloud_views(hass, data) class _BaseOnboardingView(HomeAssistantView): @@ -428,9 +428,19 @@ class UploadBackupView(_NoAuthBaseOnboardingView, backup_http.UploadBackupView): return await self._post(request) -def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: +async def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: """Set up the cloud views.""" + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # Import the cloud integration in an executor to avoid blocking the + # event loop. + def import_cloud() -> None: + """Import the cloud integration.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.cloud import http_api # noqa: F401 + + await hass.async_add_import_executor_job(import_cloud) + # The cloud integration is imported locally to avoid cloud being imported by # bootstrap.py and to avoid circular imports. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3465b24fb2a..ba28ff34157 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,6 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 +numpy==2.2.2 orjson==3.10.16 packaging>=23.1 paho-mqtt==2.1.0 diff --git a/pyproject.toml b/pyproject.toml index b90738bdbef..b5ba5a0efd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,16 +45,41 @@ dependencies = [ "ciso8601==2.3.2", "cronsim==2.6", "fnv-hash-fast==1.4.0", + # ha-ffmpeg is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.94.0", + # hassil is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "hassil==2.2.3", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", "home-assistant-bluetooth==1.13.1", + # home_assistant_intents is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "home-assistant-intents==2025.3.28", "ifaddr==0.2.0", "Jinja2==3.1.6", "lru-dict==1.3.0", + # mutagen is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "mutagen==1.47.0", + # numpy is indirectly imported from onboarding via the import chain + # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "numpy==2.2.2", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==44.0.1", @@ -64,7 +89,22 @@ dependencies = [ "orjson==3.10.16", "packaging>=23.1", "psutil-home-assistant==0.0.1", + # pymicro_vad is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pymicro-vad==1.0.1", + # pyspeex-noise is indirectly imported from onboarding via the import chain + # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "pyspeex-noise==1.0.2", "python-slugify==8.0.4", + # PyTurboJPEG is indirectly imported from onboarding via the import chain + # onboarding->cloud->camera->pyturbojpeg. Onboarding needs + # to be setup in stage 0, but we don't want to also promote cloud with all its + # dependencies to stage 0. + "PyTurboJPEG==1.7.5", "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.2.1", diff --git a/requirements.txt b/requirements.txt index bd3722b3617..0ef5062201a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,12 +22,17 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.4.0 +ha-ffmpeg==3.2.2 hass-nabucasa==0.94.0 +hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 +home-assistant-intents==2025.3.28 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 +mutagen==1.47.0 +numpy==2.2.2 PyJWT==2.10.1 cryptography==44.0.1 Pillow==11.1.0 @@ -36,7 +41,10 @@ pyOpenSSL==25.0.0 orjson==3.10.16 packaging>=23.1 psutil-home-assistant==0.0.1 +pymicro-vad==1.0.1 +pyspeex-noise==1.0.2 python-slugify==8.0.4 +PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 From 23b79b2f39de389c087fb9de0397700427cf1495 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 16:39:22 +0200 Subject: [PATCH 033/217] Capitalize app name in `deluge` description string (#142003) This should help fix / prevent some wrong translations like "impostazioni di diluvio" in Italian. --- homeassistant/components/deluge/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index 6adde8ef7df..ddea78b315f 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", + "description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", From da9b3dc68b004f80d0acba76cd6b17e6bc4336a8 Mon Sep 17 00:00:00 2001 From: tmenguy Date: Tue, 1 Apr 2025 17:14:21 +0200 Subject: [PATCH 034/217] Better throttling handling for the Renault API (#141667) * Added some better throttling handling for the Renault API, it fixes #106777 HA ticket * Added some better throttling handling for the Renault API, it fixes #106777 HA ticket, test fixing * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * bigger testsuite for #106777 HA ticket * Adjust tests * Update homeassistant/components/renault/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/renault/renault_hub.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/renault/test_sensor.py * Update tests/components/renault/test_sensor.py * Update tests/components/renault/test_sensor.py * requested changes #106777 HA ticket * Use unkown * Fix test * Fix test again * Reduce and fix * Use assumed_state * requested changes #106777 HA ticket --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/renault/const.py | 3 ++ .../components/renault/coordinator.py | 28 +++++++++++++++++++ homeassistant/components/renault/entity.py | 5 ++++ .../components/renault/renault_hub.py | 27 +++++++++++++++++- .../components/renault/renault_vehicle.py | 4 +++ tests/components/renault/test_sensor.py | 19 ++++++++++--- 6 files changed, 81 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 201a07c6783..05f8099b168 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -9,6 +9,9 @@ CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" DEFAULT_SCAN_INTERVAL = 420 # 7 minutes +# If throttled time to pause the updates, in seconds +COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes + PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index a90331730bc..c768c436133 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import ( AccessDeniedException, KamereonResponseException, NotSupportedException, + QuotaLimitException, ) from renault_api.kamereon.models import KamereonVehicleDataAttributes @@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub T = TypeVar("T", bound=KamereonVehicleDataAttributes) @@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, logger: logging.Logger, *, name: str, @@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): ) self.access_denied = False self.not_supported = False + self.assumed_state = False + self._has_already_worked = False + self._hub = hub async def _async_update_data(self) -> T: """Fetch the latest data from the source.""" + + if self._hub.is_throttled(): + if not self._has_already_worked: + raise UpdateFailed("Renault hub currently throttled: init skipped") + # we have been throttled and decided to cooldown + # so do not count this update as an error + # coordinator. last_update_success should still be ok + self.logger.debug("Renault hub currently throttled: scan skipped") + self.assumed_state = True + return self.data + try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() @@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): self.access_denied = True raise UpdateFailed(f"This endpoint is denied: {err}") from err + except QuotaLimitException as err: + # The data we got is not bad per see, initiate cooldown for all coordinators + self._hub.set_throttled() + if self._has_already_worked: + self.assumed_state = True + self.logger.warning("Renault API throttled") + return self.data + + raise UpdateFailed(f"Renault API throttled: {err}") from err + except NotSupportedException as err: # Disable because the vehicle does not support this Renault endpoint. self.update_interval = None @@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): raise UpdateFailed(f"Error communicating with API: {err}") from err self._has_already_worked = True + self.assumed_state = False return data async def async_config_entry_first_refresh(self) -> None: diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 7beb91e9603..81d81a18b7f 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -60,3 +60,8 @@ class RenaultDataEntity( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" return cast(StateType, getattr(self.coordinator.data, key)) + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self.coordinator.assumed_state diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index b37390526cf..e5168fc81fd 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -27,7 +27,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession if TYPE_CHECKING: from . import RenaultConfigEntry -from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL +from time import time + +from .const import ( + CONF_KAMEREON_ACCOUNT_ID, + COOLING_UPDATES_SECONDS, + DEFAULT_SCAN_INTERVAL, +) from .renault_vehicle import RenaultVehicleProxy LOGGER = logging.getLogger(__name__) @@ -45,6 +51,24 @@ class RenaultHub: self._account: RenaultAccount | None = None self._vehicles: dict[str, RenaultVehicleProxy] = {} + self._got_throttled_at_time: float | None = None + + def set_throttled(self) -> None: + """We got throttled, we need to adjust the rate limit.""" + if self._got_throttled_at_time is None: + self._got_throttled_at_time = time() + + def is_throttled(self) -> bool: + """Check if we are throttled.""" + if self._got_throttled_at_time is None: + return False + + if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS: + self._got_throttled_at_time = None + return False + + return True + async def attempt_login(self, username: str, password: str) -> bool: """Attempt login to Renault servers.""" try: @@ -99,6 +123,7 @@ class RenaultHub: vehicle = RenaultVehicleProxy( hass=self._hass, config_entry=config_entry, + hub=self, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1cce0e4459f..1ab9bf0bd5a 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo if TYPE_CHECKING: from . import RenaultConfigEntry + from .renault_hub import RenaultHub from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -68,6 +69,7 @@ class RenaultVehicleProxy: self, hass: HomeAssistant, config_entry: RenaultConfigEntry, + hub: RenaultHub, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, @@ -87,6 +89,7 @@ class RenaultVehicleProxy: self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 self._scan_interval = scan_interval + self._hub = hub @property def details(self) -> models.KamereonVehicleDetails: @@ -104,6 +107,7 @@ class RenaultVehicleProxy: coord.key: RenaultDataUpdateCoordinator( self.hass, self.config_entry, + self._hub, LOGGER, name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index fb5fc205a7b..bce50ec4fbf 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -10,7 +10,7 @@ from renault_api.kamereon.exceptions import QuotaLimitException from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -184,7 +184,7 @@ async def test_sensor_throttling_during_setup( for get_data_mock in patches.values(): get_data_mock.side_effect = None patches["battery_status"].return_value.batteryLevel = 55 - freezer.tick(datetime.timedelta(minutes=10)) + freezer.tick(datetime.timedelta(minutes=20)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -196,6 +196,7 @@ async def test_sensor_throttling_after_init( hass: HomeAssistant, config_entry: ConfigEntry, vehicle_type: str, + caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ) -> None: """Test for Renault sensors with a throttling error during setup.""" @@ -209,8 +210,11 @@ async def test_sensor_throttling_after_init( # Initial state entity_id = "sensor.reg_number_battery" assert hass.states.get(entity_id).state == "60" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled: scan skipped" not in caplog.text # Test QuotaLimitException state + caplog.clear() for get_data_mock in patches.values(): get_data_mock.side_effect = QuotaLimitException( "err.func.wired.overloaded", "You have reached your quota limit" @@ -219,14 +223,21 @@ async def test_sensor_throttling_after_init( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).state == "60" + assert hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" in caplog.text + assert "Renault hub currently throttled: scan skipped" in caplog.text # Test QuotaLimitException recovery, with new battery level + caplog.clear() for get_data_mock in patches.values(): get_data_mock.side_effect = None patches["battery_status"].return_value.batteryLevel = 55 - freezer.tick(datetime.timedelta(minutes=10)) + freezer.tick(datetime.timedelta(minutes=20)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "55" + assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) + assert "Renault API throttled" not in caplog.text + assert "Renault hub currently throttled: scan skipped" not in caplog.text From e0b030c8927673b6ee7f87494d81052cebb363f2 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 2 Apr 2025 00:14:39 +0900 Subject: [PATCH 035/217] Add select for dehumidifier's mode control (#140572) * Add select for dehumidifier * Add device_class POWER * Delete not related to select * Update homeassistant/components/lg_thinq/strings.json --------- Co-authored-by: yunseon.park Co-authored-by: Joost Lekkerkerker --- homeassistant/components/lg_thinq/icons.json | 3 +++ homeassistant/components/lg_thinq/select.py | 8 +++++++- homeassistant/components/lg_thinq/strings.json | 11 +++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 787b50167c1..3b0baaaaf75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -169,6 +169,9 @@ "current_job_mode": { "default": "mdi:format-list-bulleted" }, + "current_job_mode_dehumidifier": { + "default": "mdi:format-list-bulleted" + }, "operation_mode": { "default": "mdi:gesture-tap-button" }, diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index 929fa0b1d28..3f29ee9e5c8 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] = AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE], ), - DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],), + DeviceType.DEHUMIDIFIER: ( + AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH], + SelectEntityDescription( + key=ThinQProperty.CURRENT_JOB_MODE, + translation_key="current_job_mode_dehumidifier", + ), + ), DeviceType.DISH_WASHER: ( OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE], ), diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 09e3718af9b..bb3865254a3 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -928,6 +928,17 @@ "vacation": "Vacation" } }, + "current_job_mode_dehumidifier": { + "name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]", + "state": { + "air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]", + "clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]", + "intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]", + "quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]", + "rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]", + "smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]" + } + }, "operation_mode": { "name": "Operation", "state": { From 597540b61120a182ad08866a161fd106bf79d2f9 Mon Sep 17 00:00:00 2001 From: aaronburt <42388542+aaronburt@users.noreply.github.com> Date: Tue, 1 Apr 2025 16:17:34 +0100 Subject: [PATCH 036/217] Correct unit conversion for OneDrive quota display (#140337) * Correct unit conversion for OneDrive quota display * Convert OneDrive quota values from bytes to GiB in coordinator and update strings --- homeassistant/components/onedrive/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 7b2dbaab87a..3eb7d762712 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -88,8 +88,8 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): ), translation_key=key, translation_placeholders={ - "total": str(drive.quota.total), - "used": str(drive.quota.used), + "total": f"{drive.quota.total / (1024**3):.2f}", + "used": f"{drive.quota.used / (1024**3):.2f}", }, ) return drive From 935db1308f4fbfcd10da1de55aba0f2ea0304038 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 18:07:19 +0200 Subject: [PATCH 037/217] Add common states for "Low", "Medium" and "High" (#141999) --- homeassistant/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 13a6d1ef759..763d50e79d7 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -126,9 +126,12 @@ "discharging": "Discharging", "disconnected": "Disconnected", "enabled": "Enabled", + "high": "High", "home": "Home", "idle": "Idle", "locked": "Locked", + "low": "Low", + "medium": "Medium", "no": "No", "not_home": "Away", "off": "Off", From 426e9846d9d6a12bdfb599ea4557790c2397ce50 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 1 Apr 2025 18:08:36 +0200 Subject: [PATCH 038/217] Add Homee climate platform (#141616) * Add climate platform * Add climate tests * Add service tests * Add snapshot test * Code optimazitions 1 * Add test for current preset mode. * code optimization 2 * code optimization 3 * small tweaks * another small tweak * Last minute changes * Update tests/components/homee/test_climate.py Co-authored-by: Joost Lekkerkerker * fix review comments * typo * more review fixes. * maybe final review fixes. --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/climate.py | 200 +++++++++++++ homeassistant/components/homee/const.py | 3 + homeassistant/components/homee/icons.json | 11 + homeassistant/components/homee/strings.json | 11 + .../fixtures/thermostat_only_targettemp.json | 52 ++++ .../fixtures/thermostat_with_currenttemp.json | 77 +++++ .../thermostat_with_heating_mode.json | 127 ++++++++ .../fixtures/thermostat_with_preset.json | 98 +++++++ .../homee/snapshots/test_climate.ambr | 274 ++++++++++++++++++ tests/components/homee/test_climate.py | 270 +++++++++++++++++ 11 files changed, 1124 insertions(+) create mode 100644 homeassistant/components/homee/climate.py create mode 100644 tests/components/homee/fixtures/thermostat_only_targettemp.json create mode 100644 tests/components/homee/fixtures/thermostat_with_currenttemp.json create mode 100644 tests/components/homee/fixtures/thermostat_with_heating_mode.json create mode 100644 tests/components/homee/fixtures/thermostat_with_preset.json create mode 100644 tests/components/homee/snapshots/test_climate.ambr create mode 100644 tests/components/homee/test_climate.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 9fd88ee40aa..fbd34743496 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.LOCK, diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py new file mode 100644 index 00000000000..3411d31461c --- /dev/null +++ b/homeassistant/components/homee/climate.py @@ -0,0 +1,200 @@ +"""The Homee climate platform.""" + +from typing import Any + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeNode + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL +from .entity import HomeeNodeEntity + +PARALLEL_UPDATES = 0 + +ROOM_THERMOSTATS = { + NodeProfile.ROOM_THERMOSTAT, + NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, + NodeProfile.WIFI_ROOM_THERMOSTAT, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the climate component.""" + + async_add_devices( + HomeeClimate(node, config_entry) + for node in config_entry.runtime_data.nodes + if node.profile in CLIMATE_PROFILES + ) + + +class HomeeClimate(HomeeNodeEntity, ClimateEntity): + """Representation of a Homee climate entity.""" + + _attr_name = None + _attr_translation_key = DOMAIN + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a Homee climate entity.""" + super().__init__(node, entry) + + ( + self._attr_supported_features, + self._attr_hvac_modes, + self._attr_preset_modes, + ) = get_climate_features(self._node) + + self._target_temp = self._node.get_attribute_by_type( + AttributeType.TARGET_TEMPERATURE + ) + assert self._target_temp is not None + self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit]) + self._attr_target_temperature_step = self._target_temp.step_value + self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}" + + self._heating_mode = self._node.get_attribute_by_type( + AttributeType.HEATING_MODE + ) + self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE) + self._valve_position = self._node.get_attribute_by_type( + AttributeType.CURRENT_VALVE_POSITION + ) + + @property + def hvac_mode(self) -> HVACMode: + """Return the hvac operation mode.""" + if ClimateEntityFeature.TURN_OFF in self.supported_features and ( + self._heating_mode is not None + ): + if self._heating_mode.current_value == 0: + return HVACMode.OFF + + return HVACMode.HEAT + + @property + def hvac_action(self) -> HVACAction: + """Return the hvac action.""" + if self._heating_mode is not None and self._heating_mode.current_value == 0: + return HVACAction.OFF + + if ( + self._valve_position is not None and self._valve_position.current_value == 0 + ) or ( + self._temperature is not None + and self._temperature.current_value >= self.target_temperature + ): + return HVACAction.IDLE + + return HVACAction.HEATING + + @property + def preset_mode(self) -> str: + """Return the present preset mode.""" + if ( + ClimateEntityFeature.PRESET_MODE in self.supported_features + and self._heating_mode is not None + and self._heating_mode.current_value > 0 + ): + assert self._attr_preset_modes is not None + return self._attr_preset_modes[int(self._heating_mode.current_value) - 1] + + return PRESET_NONE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if self._temperature is not None: + return self._temperature.current_value + return None + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + assert self._target_temp is not None + return self._target_temp.current_value + + @property + def min_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.minimum + + @property + def max_temp(self) -> float: + """Return the lowest settable target temperature.""" + assert self._target_temp is not None + return self._target_temp.maximum + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + # Currently only HEAT and OFF are supported. + assert self._heating_mode is not None + await self.async_set_homee_value( + self._heating_mode, float(hvac_mode == HVACMode.HEAT) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + assert self._heating_mode is not None and self._attr_preset_modes is not None + await self.async_set_homee_value( + self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1 + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + assert self._target_temp is not None + if ATTR_TEMPERATURE in kwargs: + await self.async_set_homee_value( + self._target_temp, kwargs[ATTR_TEMPERATURE] + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 1) + + async def async_turn_off(self) -> None: + """Turn the entity on.""" + assert self._heating_mode is not None + await self.async_set_homee_value(self._heating_mode, 0) + + +def get_climate_features( + node: HomeeNode, +) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]: + """Determine supported climate features of a node based on the available attributes.""" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [HVACMode.HEAT] + preset_modes: list[str] = [] + + if ( + attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE) + ) is not None: + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + hvac_modes.append(HVACMode.OFF) + + if attribute.maximum > 1: + # Node supports more modes than off and heating. + features |= ClimateEntityFeature.PRESET_MODE + preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL]) + + if len(preset_modes) > 0: + preset_modes.insert(0, PRESET_NONE) + return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 2c614d3f5eb..468fb2d49ac 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -95,3 +95,6 @@ LIGHT_PROFILES = [ NodeProfile.WIFI_DIMMABLE_LIGHT, NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, ] + +# Climate Presets +PRESET_MANUAL = "manual" diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index b4ad8871568..d6d327a32c5 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,5 +1,16 @@ { "entity": { + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "mdi:hand-back-left" + } + } + } + } + }, "sensor": { "brightness": { "default": "mdi:brightness-5" diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 3dbbdcd2004..623a4e93895 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -131,6 +131,17 @@ "name": "Ventilate" } }, + "climate": { + "homee": { + "state_attributes": { + "preset_mode": { + "state": { + "manual": "Manual" + } + } + } + } + }, "light": { "light_instance": { "name": "Light {instance}" diff --git a/tests/components/homee/fixtures/thermostat_only_targettemp.json b/tests/components/homee/fixtures/thermostat_only_targettemp.json new file mode 100644 index 00000000000..4bdbaa0df78 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_only_targettemp.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Thermostat 1", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 12, + "maximum": 28, + "current_value": 20.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_currenttemp.json b/tests/components/homee/fixtures/thermostat_with_currenttemp.json new file mode 100644 index 00000000000..9685034f178 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_currenttemp.json @@ -0,0 +1,77 @@ +{ + "id": 2, + "name": "Test Thermostat 2", + "profile": 3003, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 2, + "instance": 0, + "minimum": 15, + "maximum": 30, + "current_value": 22.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 2, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_heating_mode.json b/tests/components/homee/fixtures/thermostat_with_heating_mode.json new file mode 100644 index 00000000000..fe06e9ef4a5 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_heating_mode.json @@ -0,0 +1,127 @@ +{ + "id": 3, + "name": "Test Thermostat 3", + "profile": 3006, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 14, + "maximum": 25, + "current_value": 24.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/fixtures/thermostat_with_preset.json b/tests/components/homee/fixtures/thermostat_with_preset.json new file mode 100644 index 00000000000..63491d45be2 --- /dev/null +++ b/tests/components/homee/fixtures/thermostat_with_preset.json @@ -0,0 +1,98 @@ +{ + "id": 4, + "name": "Test Thermostat 4", + "profile": 3033, + "image": "default", + "favorite": 0, + "order": 32, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1712840187, + "added": 1655274291, + "history": 1, + "cube_type": 1, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 4, + "instance": 0, + "minimum": 10, + "maximum": 32, + "current_value": 12.0, + "target_value": 13.0, + "last_value": 12.0, + "unit": "°C", + "step_value": 0.5, + "editable": 1, + "type": 6, + "state": 2, + "last_changed": 1713695529, + "changed_by": 3, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 4, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 19.55, + "target_value": 19.55, + "last_value": 21.07, + "unit": "°C", + "step_value": 0.1, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1713695528, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "observed_by": [240], + "history": { "day": 1, "week": 26, "month": 6 } + } + }, + { + "id": 3, + "node_id": 4, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 258, + "state": 1, + "last_changed": 1711796635, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_climate.ambr b/tests/components/homee/snapshots/test_climate.ambr new file mode 100644 index 00000000000..b79538ddcf0 --- /dev/null +++ b/tests/components/homee/snapshots/test_climate.ambr @@ -0,0 +1,274 @@ +# serializer version: 1 +# name: test_climate_snapshot[climate.test_thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 28, + 'min_temp': 12, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-2-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 2', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 15, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 3', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 25, + 'min_temp': 14, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 24.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_thermostat_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'homee', + 'unique_id': '00055511EECC-4-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_snapshot[climate.test_thermostat_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.6, + 'friendly_name': 'Test Thermostat 4', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 10, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'eco', + 'boost', + 'manual', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.test_thermostat_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/homee/test_climate.py b/tests/components/homee/test_climate.py new file mode 100644 index 00000000000..bb5ad98c7d2 --- /dev/null +++ b/tests/components/homee/test_climate.py @@ -0,0 +1,270 @@ +"""Test Homee climate entities.""" + +from unittest.mock import MagicMock, patch + +from pyHomee.const import AttributeType +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homee.const import PRESET_MANUAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_mock_climate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, +) -> None: + """Setups a climate node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("file", "entity_id", "features", "hvac_modes"), + [ + ( + "thermostat_only_targettemp.json", + "climate.test_thermostat_1", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_currenttemp.json", + "climate.test_thermostat_2", + ClimateEntityFeature.TARGET_TEMPERATURE, + [HVACMode.HEAT], + ), + ( + "thermostat_with_heating_mode.json", + "climate.test_thermostat_3", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, + [HVACMode.HEAT, HVACMode.OFF], + ), + ( + "thermostat_with_preset.json", + "climate.test_thermostat_4", + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE, + [HVACMode.HEAT, HVACMode.OFF], + ), + ], +) +async def test_climate_features( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + file: str, + entity_id: str, + features: ClimateEntityFeature, + hvac_modes: list[HVACMode], +) -> None: + """Test available features of climate entities.""" + await setup_mock_climate(hass, mock_config_entry, mock_homee, file) + + attributes = hass.states.get(entity_id).attributes + assert attributes["supported_features"] == features + assert attributes[ATTR_HVAC_MODES] == hvac_modes + + +async def test_climate_preset_modes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test available preset modes of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODES] == [ + PRESET_NONE, + PRESET_ECO, + PRESET_BOOST, + PRESET_MANUAL, + ] + + +@pytest.mark.parametrize( + ("attribute_type", "value", "expected"), + [ + (AttributeType.HEATING_MODE, 0.0, HVACAction.OFF), + (AttributeType.CURRENT_VALVE_POSITION, 0.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 25.0, HVACAction.IDLE), + (AttributeType.TEMPERATURE, 18.0, HVACAction.HEATING), + ], +) +async def test_hvac_action( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attribute_type: AttributeType, + value: float, + expected: HVACAction, +) -> None: + """Test hvac action of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_heating_mode.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + # set target temperature to 24.0 + node.attributes[0].current_value = 24.0 + attribute = node.get_attribute_by_type(attribute_type) + attribute.current_value = value + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_3").attributes + assert attributes[ATTR_HVAC_ACTION] == expected + + +@pytest.mark.parametrize( + ("preset_mode_int", "expected"), + [ + (0, PRESET_NONE), + (1, PRESET_NONE), + (2, PRESET_ECO), + (3, PRESET_BOOST), + (4, PRESET_MANUAL), + ], +) +async def test_current_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + preset_mode_int: int, + expected: str, +) -> None: + """Test current preset mode of climate entities.""" + mock_homee.nodes = [build_mock_node("thermostat_with_preset.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + node = mock_homee.nodes[0] + node.attributes[2].current_value = preset_mode_int + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("climate.test_thermostat_4").attributes + assert attributes[ATTR_PRESET_MODE] == expected + + +@pytest.mark.parametrize( + ("service", "service_data", "expected"), + [ + ( + SERVICE_TURN_ON, + {}, + (4, 3, 1), + ), + ( + SERVICE_TURN_OFF, + {}, + (4, 3, 0), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + (4, 3, 1), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + (4, 3, 0), + ), + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 20}, + (4, 1, 20), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_NONE}, + (4, 3, 1), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_ECO}, + (4, 3, 2), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_BOOST}, + (4, 3, 3), + ), + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_MANUAL}, + (4, 3, 4), + ), + ], +) +async def test_climate_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + service_data: dict, + expected: tuple[int, int, int], +) -> None: + """Test available services of climate entities.""" + await setup_mock_climate( + hass, mock_config_entry, mock_homee, "thermostat_with_preset.json" + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: "climate.test_thermostat_4", **service_data}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(*expected) + + +async def test_climate_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of climates.""" + mock_homee.nodes = [ + build_mock_node("thermostat_only_targettemp.json"), + build_mock_node("thermostat_with_currenttemp.json"), + build_mock_node("thermostat_with_heating_mode.json"), + build_mock_node("thermostat_with_preset.json"), + ] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 6007629293e23d076a1672c0df7216aa80ba06c9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 1 Apr 2025 19:19:53 +0200 Subject: [PATCH 039/217] Update frontend to 20250401.0 (#142010) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_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 ef974177947..4cab8375d1b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250331.0"] + "requirements": ["home-assistant-frontend==20250401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ba28ff34157..291d47ec4cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250331.0 +home-assistant-frontend==20250401.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e8d1e5ea77f..b906e92998c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250331.0 +home-assistant-frontend==20250401.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77a70929022..ff4f0e15af1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250331.0 +home-assistant-frontend==20250401.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From d9cd62bf6589519128c1d5d280187e05aeb7e7ab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 1 Apr 2025 19:20:31 +0200 Subject: [PATCH 040/217] Add LG ThinQ event bus listener to lifecycle hooks (#142006) --- homeassistant/components/lg_thinq/coordinator.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 513cd27a7b2..9f84c422277 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -63,10 +63,12 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Add a callback to handle core config update. self.unit_system: str | None = None - self.hass.bus.async_listen( - event_type=EVENT_CORE_CONFIG_UPDATE, - listener=self._handle_update_config, - event_filter=self.async_config_update_filter, + self.config_entry.async_on_unload( + self.hass.bus.async_listen( + event_type=EVENT_CORE_CONFIG_UPDATE, + listener=self._handle_update_config, + event_filter=self.async_config_update_filter, + ) ) async def _handle_update_config(self, _: Event) -> None: From faac51d2198c4f78c4130080ce01923dc4c74a52 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 1 Apr 2025 19:22:32 +0200 Subject: [PATCH 041/217] Improve error handling and logging on MQTT update entity state updates when template rederings fails (#141960) --- homeassistant/components/mqtt/update.py | 15 +++++- tests/components/mqtt/test_update.py | 66 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index c4916b5010c..145f0a2562c 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -26,7 +26,7 @@ from . import subscription from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import MqttValueTemplate, ReceiveMessage +from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic @@ -136,7 +136,18 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @callback def _handle_state_message_received(self, msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" - payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + payload = self._templates[CONF_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + + if payload is PayloadSentinel.DEFAULT: + _LOGGER.warning( + "Unable to process payload '%s' for topic %s, with value template '%s'", + msg.payload, + msg.topic, + self._config.get(CONF_VALUE_TEMPLATE), + ) + return if not payload or payload == PAYLOAD_EMPTY_JSON: _LOGGER.debug( diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index d70d7dd792b..87eb381db03 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -1,6 +1,7 @@ """The tests for mqtt update component.""" import json +from typing import Any from unittest.mock import patch import pytest @@ -225,6 +226,71 @@ async def test_value_template( assert state.attributes.get("latest_version") == "2.0.0" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/update", + "value_template": ( + "{\"latest_version\":\"{{ value_json['update']['latest_version'] }}\"," + "\"installed_version\":\"{{ value_json['update']['installed_version'] }}\"," + "\"update_percentage\":{{ value_json['update'].get('progress', 'null') }}}" + ), + "name": "Test Update", + } + } + } + ], +) +async def test_errornous_value_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that it fetches the given payload with a template or handles the exception.""" + state_topic = "test/update" + await mqtt_mock_entry() + + # Simulate a template redendering error with payload + # without "update" mapping + example_payload: dict[str, Any] = { + "child_lock": "UNLOCK", + "current": 0.02, + "energy": 212.92, + "indicator_mode": "off/on", + "linkquality": 65, + "power": 0, + "power_outage_memory": "off", + "state": "ON", + "voltage": 232, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + assert hass.states.get("update.test_update") is not None + assert "Unable to process payload '" in caplog.text + + # Add update info + example_payload["update"] = { + "latest_version": "2.0.0", + "installed_version": "1.9.0", + "progress": 20, + } + + async_fire_mqtt_message(hass, state_topic, json.dumps(example_payload)) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state is not None + + assert state.state == STATE_ON + assert state.attributes.get("installed_version") == "1.9.0" + assert state.attributes.get("latest_version") == "2.0.0" + assert state.attributes.get("update_percentage") == 20 + + @pytest.mark.parametrize( "hass_config", [ From 4bfc96c02bb5d4f72c6a457da21d9016ef149f2c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 1 Apr 2025 19:36:14 +0200 Subject: [PATCH 042/217] Improve SmartThings deprecation (#141939) * Improve SmartThings deprecation * Improve SmartThings deprecation --- .../components/smartthings/binary_sensor.py | 141 ++++++++--------- .../components/smartthings/strings.json | 16 +- homeassistant/components/smartthings/util.py | 83 ++++++++++ .../snapshots/test_binary_sensor.ambr | 144 ------------------ .../smartthings/test_binary_sensor.py | 126 +++++++++++++-- 5 files changed, 273 insertions(+), 237 deletions(-) create mode 100644 homeassistant/components/smartthings/util.py diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index bd09f1725d3..75a080975ea 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -7,26 +7,21 @@ from dataclasses import dataclass from pysmartthings import Attribute, Capability, Category, SmartThings, Status -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import FullDevice, SmartThingsConfigEntry -from .const import DOMAIN, MAIN +from .const import MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity @dataclass(frozen=True, kw_only=True) @@ -192,24 +187,64 @@ async def async_setup_entry( ) -> None: """Add binary sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsBinarySensor( - entry_data.client, device, description, capability, attribute, component - ) - for device in entry_data.devices.values() - for capability, attribute_map in CAPABILITY_TO_SENSORS.items() - for attribute, description in attribute_map.items() - for component in device.status - if capability in device.status[component] - and ( - component == MAIN - or (description.exists_fn is not None and description.exists_fn(component)) - ) - and ( - not description.category - or get_main_component_category(device) in description.category - ) - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attribute_map in CAPABILITY_TO_SENSORS.items(): + for attribute, description in attribute_map.items(): + for component in device.status: + if ( + capability in device.status[component] + and ( + component == MAIN + or ( + description.exists_fn is not None + and description.exists_fn(component) + ) + ) + and ( + not description.category + or get_main_component_category(device) + in description.category + ) + ): + if ( + component == MAIN + and (issue := description.deprecated_fn(device.status)) + is not None + ): + if deprecate_entity( + hass, + entity_registry, + BINARY_SENSOR_DOMAIN, + f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}", + f"deprecated_binary_{issue}", + ): + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + continue + entities.append( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + ) + + async_add_entities(entities) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): @@ -257,57 +292,3 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self.get_attribute_value(self.capability, self._attribute) == self.entity_description.is_on_key ) - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - if (issue := self.entity_description.deprecated_fn(self.device.status)) is None: - return - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_{issue}_{self.entity_id}", - breaks_in_ha_version="2025.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_binary_{issue}", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if (issue := self.entity_description.deprecated_fn(self.device.status)) is None: - return - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_{issue}_{self.entity_id}" - ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fc3ca66a3af..1fbe535261e 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -480,12 +480,20 @@ }, "issues": { "deprecated_binary_valve": { - "title": "Deprecated valve binary sensor detected in some automations or scripts", - "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts to fix this issue." + "title": "Valve binary sensor deprecated", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_valve_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]", + "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_binary_fridge_door": { - "title": "Deprecated refrigerator door binary sensor detected in some automations or scripts", - "description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts to fix this issue." + "title": "Refrigerator door binary sensor deprecated", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_binary_fridge_door_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]", + "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_appliance": { "title": "Deprecated switch detected in some automations or scripts", diff --git a/homeassistant/components/smartthings/util.py b/homeassistant/components/smartthings/util.py new file mode 100644 index 00000000000..b21652ca629 --- /dev/null +++ b/homeassistant/components/smartthings/util.py @@ -0,0 +1,83 @@ +"""Utility functions for SmartThings 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 + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + platform_domain: str, + entity_unique_id: str, + issue_string: str, +) -> bool: + """Create an issue for deprecated entities.""" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ): + entity_entry = entity_registry.async_get(entity_id) + if not entity_entry: + return False + if entity_entry.disabled: + entity_registry.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + ) + return False + translation_key = issue_string + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or "Unknown", + } + if items := get_automations_and_scripts_using_entity(hass, entity_id): + translation_key = f"{translation_key}_scripts" + placeholders.update( + { + "items": "\n".join(items), + } + ) + async_create_issue( + hass, + DOMAIN, + f"{issue_string}_{entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + return False + + +def get_automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_id: str, +) -> list[str]: + """Get 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 [] + + entity_reg = er.async_get(hass) + return [ + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ) + for entity_id in entities + if (item := entity_reg.async_get(entity_id)) + ] diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index d41c36aea64..2419a154e05 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -713,54 +713,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.refrigerator_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_contactSensor_contact_contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Refrigerator Door', - }), - 'context': , - 'entity_id': 'binary_sensor.refrigerator_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -857,54 +809,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.frigo_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_contactSensor_contact_contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Frigo Door', - }), - 'context': , - 'entity_id': 'binary_sensor.frigo_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2139,54 +2043,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.volvo_valve', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Valve', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'valve', - 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_main_valve_valve_valve', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'opening', - 'friendly_name': 'volvo Valve', - }), - 'context': , - 'entity_id': 'binary_sensor.volvo_valve', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 517de034613..f7fcde3746f 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -8,8 +8,9 @@ from syrupy import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.script import scripts_with_entity -from homeassistant.components.smartthings import DOMAIN +from homeassistant.components.smartthings import DOMAIN, MAIN from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -44,7 +45,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_OFF await trigger_update( hass, @@ -53,35 +54,60 @@ async def test_state_update( Capability.CONTACT_SENSOR, Attribute.CONTACT, "open", + component="cooler", ) - assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "issue_string", "entity_id"), + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), [ - ("virtual_valve", "valve", "binary_sensor.volvo_valve"), - ("da_ref_normal_000001", "fridge_door", "binary_sensor.refrigerator_door"), + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), ], ) -async def test_create_issue( +async def test_create_issue_with_items( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, issue_string: str, entity_id: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { + "id": "test", "alias": "test", "trigger": {"platform": "state", "entity_id": entity_id}, "action": { @@ -113,13 +139,95 @@ async def test_create_issue( await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state == STATE_OFF + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}_scripts" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } - await hass.config_entries.async_unload(mock_config_entry.entry_id) + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), + [ + ( + "virtual_valve", + f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", + "volvo_valve", + "valve", + "binary_sensor.volvo_valve", + ), + ( + "da_ref_normal_000001", + f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", + "refrigerator_door", + "fridge_door", + "binary_sensor.refrigerator_door", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_binary_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == STATE_OFF + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_binary_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present From c28a6a867dabde58af9b50d23b8d9b9f8a2bd626 Mon Sep 17 00:00:00 2001 From: Mikko Koo <43339021+98ultimate@users.noreply.github.com> Date: Tue, 1 Apr 2025 20:45:23 +0300 Subject: [PATCH 043/217] Fix nordpool Not to return Unknown if price is exactly 0 (#140647) * now the price will return even if it is exactly 0 * now the price will return even if it is exactly 0 * now the price will return even if it is exactly 0 * clean code * clean code * update testing code coverage * change zero testing to SE4 * remove row duplicate * fix date comments * improve testing * simplify if-return-0 * remove unnecessary tests * order testing rows * restore test_sensor_no_next_price * remove_average_price_test * fix test name --- homeassistant/components/nordpool/sensor.py | 2 +- .../nordpool/fixtures/delivery_period_today.json | 2 +- .../nordpool/snapshots/test_diagnostics.ambr | 2 +- .../components/nordpool/snapshots/test_sensor.ambr | 8 ++++---- tests/components/nordpool/test_sensor.py | 13 +++++++++++++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index c6993826239..4bde12afc3c 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -34,7 +34,7 @@ def validate_prices( index: int, ) -> float | None: """Validate and return.""" - if result := func(entity)[area][index]: + if (result := func(entity)[area][index]) is not None: return result / 1000 return None diff --git a/tests/components/nordpool/fixtures/delivery_period_today.json b/tests/components/nordpool/fixtures/delivery_period_today.json index 77d51dc9433..df48c32a9a9 100644 --- a/tests/components/nordpool/fixtures/delivery_period_today.json +++ b/tests/components/nordpool/fixtures/delivery_period_today.json @@ -162,7 +162,7 @@ "deliveryEnd": "2024-11-05T19:00:00Z", "entryPerArea": { "SE3": 1011.77, - "SE4": 1804.46 + "SE4": 0.0 } }, { diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr index 76a3dd96405..d7f7c4041cd 100644 --- a/tests/components/nordpool/snapshots/test_diagnostics.ambr +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -519,7 +519,7 @@ 'deliveryStart': '2024-11-05T18:00:00Z', 'entryPerArea': dict({ 'SE3': 1011.77, - 'SE4': 1804.46, + 'SE4': 0.0, }), }), dict({ diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 86aa49357c5..be2b04cc520 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -1332,7 +1332,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.80446', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_daily_average-entry] @@ -1580,9 +1580,9 @@ # name: test_sensor[sensor.nord_pool_se4_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'end': '2024-11-05T03:00:00+00:00', + 'end': '2024-11-05T19:00:00+00:00', 'friendly_name': 'Nord Pool SE4 Lowest price', - 'start': '2024-11-05T02:00:00+00:00', + 'start': '2024-11-05T18:00:00+00:00', 'unit_of_measurement': 'SEK/kWh', }), 'context': , @@ -1590,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.06519', + 'state': '0.0', }) # --- # name: test_sensor[sensor.nord_pool_se4_next_price-entry] diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index 60be1ee3258..082684a2a02 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -33,6 +33,19 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_current_price_is_0( + hass: HomeAssistant, load_int: ConfigEntry +) -> None: + """Test the Nord Pool sensor working if price is 0.""" + + current_price = hass.states.get("sensor.nord_pool_se4_current_price") + + assert current_price is not None + assert current_price.state == "0.0" # SE4 2024-11-05T18:00:00Z + + @pytest.mark.freeze_time("2024-11-05T23:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_no_next_price(hass: HomeAssistant, load_int: ConfigEntry) -> None: From 704777444c5f6c20010b6e10a7473d06c8e66883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 1 Apr 2025 19:02:24 +0100 Subject: [PATCH 044/217] Refactor Whirlpool sensor platform (#141958) * Refactor Whirlpool sensor platform * Rename sensor classes * Remove unused logging * Split washer dryer translation keys to use icon translations * Address review comments * Remove entity name; fix sentence casing --- homeassistant/components/whirlpool/entity.py | 38 ++++ homeassistant/components/whirlpool/icons.json | 12 ++ homeassistant/components/whirlpool/sensor.py | 179 ++++++++---------- .../components/whirlpool/strings.json | 75 +++++--- tests/components/whirlpool/conftest.py | 8 +- .../whirlpool/snapshots/test_diagnostics.ambr | 4 +- tests/components/whirlpool/test_sensor.py | 4 +- 7 files changed, 187 insertions(+), 133 deletions(-) create mode 100644 homeassistant/components/whirlpool/entity.py create mode 100644 homeassistant/components/whirlpool/icons.json diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py new file mode 100644 index 00000000000..e74ed596e1e --- /dev/null +++ b/homeassistant/components/whirlpool/entity.py @@ -0,0 +1,38 @@ +"""Base entity for the Whirlpool integration.""" + +from whirlpool.appliance import Appliance + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class WhirlpoolEntity(Entity): + """Base class for Whirlpool entities.""" + + _attr_has_entity_name = True + + def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: + """Initialize the entity.""" + self._appliance = appliance + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, appliance.said)}, + name=appliance.name.capitalize(), + manufacturer="Whirlpool", + ) + self._attr_unique_id = f"{appliance.said}{unique_id_suffix}" + + async def async_added_to_hass(self) -> None: + """Register attribute updates callback.""" + self._appliance.register_attr_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Unregister attribute updates callback.""" + self._appliance.unregister_attr_callback(self.async_write_ha_state) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._appliance.get_online() diff --git a/homeassistant/components/whirlpool/icons.json b/homeassistant/components/whirlpool/icons.json new file mode 100644 index 00000000000..574b491090e --- /dev/null +++ b/homeassistant/components/whirlpool/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "washer_state": { + "default": "mdi:washing-machine" + }, + "dryer_state": { + "default": "mdi:tumble-dryer" + } + } + } +} diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 3d38883b901..d167e3aa730 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -3,8 +3,9 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -import logging +from typing import override +from whirlpool.appliance import Appliance from whirlpool.washerdryer import MachineState, WasherDryer from homeassistant.components.sensor import ( @@ -13,16 +14,17 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +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 . import WhirlpoolConfigEntry -from .const import DOMAIN +from .entity import WhirlpoolEntity -TANK_FILL = { +SCAN_INTERVAL = timedelta(minutes=5) + +WASHER_TANK_FILL = { "0": "unknown", "1": "empty", "2": "25", @@ -31,7 +33,7 @@ TANK_FILL = { "5": "active", } -MACHINE_STATE = { +WASHER_DRYER_MACHINE_STATE = { MachineState.Standby: "standby", MachineState.Setting: "setting", MachineState.DelayCountdownMode: "delay_countdown", @@ -53,7 +55,7 @@ MACHINE_STATE = { MachineState.SystemInit: "system_initialize", } -CYCLE_FUNC = [ +WASHER_DRYER_CYCLE_FUNC = [ (WasherDryer.get_cycle_status_filling, "cycle_filling"), (WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"), (WasherDryer.get_cycle_status_sensing, "cycle_sensing"), @@ -62,60 +64,69 @@ CYCLE_FUNC = [ (WasherDryer.get_cycle_status_washing, "cycle_washing"), ] -DOOR_OPEN = "door_open" - -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=5) +STATE_DOOR_OPEN = "door_open" -def washer_state(washer: WasherDryer) -> str | None: - """Determine correct states for a washer.""" +def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: + """Determine correct states for a washer/dryer.""" - if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1": - return DOOR_OPEN + if washer_dryer.get_attribute("Cavity_OpStatusDoorOpen") == "1": + return STATE_DOOR_OPEN - machine_state = washer.get_machine_state() + machine_state = washer_dryer.get_machine_state() if machine_state == MachineState.RunningMainCycle: - for func, cycle_name in CYCLE_FUNC: - if func(washer): + for func, cycle_name in WASHER_DRYER_CYCLE_FUNC: + if func(washer_dryer): return cycle_name - return MACHINE_STATE.get(machine_state) + return WASHER_DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) class WhirlpoolSensorEntityDescription(SensorEntityDescription): - """Describes Whirlpool Washer sensor entity.""" + """Describes a Whirlpool sensor entity.""" - value_fn: Callable + value_fn: Callable[[Appliance], str | None] -SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( +WASHER_DRYER_STATE_OPTIONS = ( + list(WASHER_DRYER_MACHINE_STATE.values()) + + [value for _, value in WASHER_DRYER_CYCLE_FUNC] + + [STATE_DOOR_OPEN] +) + +WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", - translation_key="whirlpool_machine", + translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=( - list(MACHINE_STATE.values()) - + [value for _, value in CYCLE_FUNC] - + [DOOR_OPEN] - ), - value_fn=washer_state, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", translation_key="whirlpool_tank", entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=list(TANK_FILL.values()), - value_fn=lambda WasherDryer: TANK_FILL.get( - WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level") + options=list(WASHER_TANK_FILL.values()), + value_fn=lambda washer: WASHER_TANK_FILL.get( + washer.get_attribute("WashCavity_OpStatusBulkDispense1Level") ), ), ) -SENSOR_TIMER: tuple[SensorEntityDescription] = ( +DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( + WhirlpoolSensorEntityDescription( + key="state", + translation_key="dryer_state", + device_class=SensorDeviceClass.ENUM, + options=WASHER_DRYER_STATE_OPTIONS, + value_fn=washer_dryer_state, + ), +) + +WASHER_DRYER_TIME_SENSORS: tuple[SensorEntityDescription] = ( SensorEntityDescription( key="timeremaining", translation_key="end_time", @@ -134,106 +145,71 @@ async def async_setup_entry( entities: list = [] appliances_manager = config_entry.runtime_data for washer_dryer in appliances_manager.washer_dryers: + sensor_descriptions = ( + DRYER_SENSORS + if "dryer" in washer_dryer.appliance_info.data_model.lower() + else WASHER_SENSORS + ) + entities.extend( - [WasherDryerClass(washer_dryer, description) for description in SENSORS] + WhirlpoolSensor(washer_dryer, description) + for description in sensor_descriptions ) entities.extend( - [ - WasherDryerTimeClass(washer_dryer, description) - for description in SENSOR_TIMER - ] + WasherDryerTimeSensor(washer_dryer, description) + for description in WASHER_DRYER_TIME_SENSORS ) async_add_entities(entities) -class WasherDryerClass(SensorEntity): - """A class for the whirlpool/maytag washer account.""" +class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): + """A class for the Whirlpool sensors.""" _attr_should_poll = False - _attr_has_entity_name = True def __init__( - self, washer_dryer: WasherDryer, description: WhirlpoolSensorEntityDescription + self, appliance: Appliance, description: WhirlpoolSensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer - - self._attr_icon = ( - "mdi:tumble-dryer" - if "dryer" in washer_dryer.appliance_info.data_model.lower() - else "mdi:washing-machine" - ) - + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description: WhirlpoolSensorEntityDescription = description - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" - - async def async_added_to_hass(self) -> None: - """Register updates callback.""" - self._wd.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Unregister updates callback.""" - self._wd.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() @property def native_value(self) -> StateType | str: """Return native value of sensor.""" - return self.entity_description.value_fn(self._wd) + return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeClass(RestoreSensor): - """A timestamp class for the whirlpool/maytag washer account.""" +class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): + """A timestamp class for the Whirlpool washer/dryer.""" _attr_should_poll = True - _attr_has_entity_name = True def __init__( self, washer_dryer: WasherDryer, description: SensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washer_dryer + super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + self.entity_description = description - self.entity_description: SensorEntityDescription = description + self._wd = washer_dryer self._running: bool | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, washer_dryer.said)}, - name=washer_dryer.name.capitalize(), - manufacturer="Whirlpool", - ) - self._attr_unique_id = f"{washer_dryer.said}-{description.key}" + self._value: datetime | None = None async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" + """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): - self._attr_native_value = restored_data.native_value + if isinstance(restored_data.native_value, datetime): + self._value = restored_data.native_value await super().async_added_to_hass() - self._wd.register_attr_callback(self.update_from_latest_data) - - async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" - self._wd.unregister_attr_callback(self.update_from_latest_data) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._wd.get_online() async def async_update(self) -> None: """Update status of Whirlpool.""" await self._wd.fetch_data() - @callback - def update_from_latest_data(self) -> None: + @override + @property + def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" machine_state = self._wd.get_machine_state() now = utcnow() @@ -243,8 +219,7 @@ class WasherDryerTimeClass(RestoreSensor): and self._running ): self._running = False - self._attr_native_value = now - self._async_write_ha_state() + self._value = now if machine_state is MachineState.RunningMainCycle: self._running = True @@ -253,9 +228,9 @@ class WasherDryerTimeClass(RestoreSensor): seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if self._attr_native_value is None or ( - isinstance(self._attr_native_value, datetime) - and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) + if self._value is None or ( + isinstance(self._value, datetime) + and abs(new_timestamp - self._value) > timedelta(seconds=60) ): - self._attr_native_value = new_timestamp - self._async_write_ha_state() + self._value = new_timestamp + return self._value diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 95df3fb9098..56fee795237 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -43,35 +43,64 @@ }, "entity": { "sensor": { - "whirlpool_machine": { - "name": "State", + "washer_state": { "state": { "standby": "[%key:common::state::standby%]", "setting": "Setting", - "delay_countdown": "Delay Countdown", - "delay_paused": "Delay Paused", - "smart_delay": "Smart Delay", - "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]", + "delay_countdown": "Delay countdown", + "delay_paused": "Delay paused", + "smart_delay": "Smart delay", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", "pause": "[%key:common::state::paused%]", - "running_maincycle": "Running Maincycle", - "running_postcycle": "Running Postcycle", + "running_maincycle": "Running maincycle", + "running_postcycle": "Running postcycle", "exception": "Exception", "complete": "Complete", - "power_failure": "Power Failure", - "service_diagnostic_mode": "Service Diagnostic Mode", - "factory_diagnostic_mode": "Factory Diagnostic Mode", - "life_test": "Life Test", - "customer_focus_mode": "Customer Focus Mode", - "demo_mode": "Demo Mode", - "hard_stop_or_error": "Hard Stop or Error", - "system_initialize": "System Initialize", - "cycle_filling": "Cycle Filling", - "cycle_rinsing": "Cycle Rinsing", - "cycle_sensing": "Cycle Sensing", - "cycle_soaking": "Cycle Soaking", - "cycle_spinning": "Cycle Spinning", - "cycle_washing": "Cycle Washing", - "door_open": "Door Open" + "power_failure": "Power failure", + "service_diagnostic_mode": "Service diagnostic mode", + "factory_diagnostic_mode": "Factory diagnostic mode", + "life_test": "Life test", + "customer_focus_mode": "Customer focus mode", + "demo_mode": "Demo mode", + "hard_stop_or_error": "Hard stop or error", + "system_initialize": "System initialize", + "cycle_filling": "Cycle filling", + "cycle_rinsing": "Cycle rinsing", + "cycle_sensing": "Cycle sensing", + "cycle_soaking": "Cycle soaking", + "cycle_spinning": "Cycle spinning", + "cycle_washing": "Cycle washing", + "door_open": "Door open" + } + }, + "dryer_state": { + "state": { + "standby": "[%key:common::state::standby%]", + "setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]", + "delay_countdown": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_countdown%]", + "delay_paused": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_paused%]", + "smart_delay": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]", + "pause": "[%key:common::state::paused%]", + "running_maincycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_maincycle%]", + "running_postcycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_postcycle%]", + "exception": "[%key:component::whirlpool::entity::sensor::washer_state::state::exception%]", + "complete": "[%key:component::whirlpool::entity::sensor::washer_state::state::complete%]", + "power_failure": "[%key:component::whirlpool::entity::sensor::washer_state::state::power_failure%]", + "service_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::service_diagnostic_mode%]", + "factory_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::factory_diagnostic_mode%]", + "life_test": "[%key:component::whirlpool::entity::sensor::washer_state::state::life_test%]", + "customer_focus_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::customer_focus_mode%]", + "demo_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::demo_mode%]", + "hard_stop_or_error": "[%key:component::whirlpool::entity::sensor::washer_state::state::hard_stop_or_error%]", + "system_initialize": "[%key:component::whirlpool::entity::sensor::washer_state::state::system_initialize%]", + "cycle_filling": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_filling%]", + "cycle_rinsing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_rinsing%]", + "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]", + "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]", + "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]", + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]", + "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]" } }, "whirlpool_tank": { diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 93881d3735a..97550729761 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -153,12 +153,12 @@ def side_effect_function(*args, **kwargs): return None -def get_sensor_mock(said): +def get_sensor_mock(said: str, data_model: str): """Get a mock of a sensor.""" mock_sensor = mock.Mock(said=said) mock_sensor.name = f"WasherDryer {said}" mock_sensor.register_attr_callback = MagicMock() - mock_sensor.appliance_info.data_model = "washer_dryer_model" + mock_sensor.appliance_info.data_model = data_model mock_sensor.appliance_info.category = "washer_dryer" mock_sensor.appliance_info.model_number = "12345" mock_sensor.get_online.return_value = True @@ -179,13 +179,13 @@ def get_sensor_mock(said): @pytest.fixture(name="mock_sensor1_api", autouse=False) def fixture_mock_sensor1_api(): """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID3) + return get_sensor_mock(MOCK_SAID3, "washer") @pytest.fixture(name="mock_sensor2_api", autouse=False) def fixture_mock_sensor2_api(): """Set up sensor API fixture.""" - return get_sensor_mock(MOCK_SAID4) + return get_sensor_mock(MOCK_SAID4, "dryer") @pytest.fixture(name="mock_sensor_api_instances", autouse=False) diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index 7ffae8bc808..7294e914f51 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -19,12 +19,12 @@ 'washer_dryers': dict({ 'WasherDryer said3': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'washer', 'model_number': '12345', }), 'WasherDryer said4': dict({ 'category': 'washer_dryer', - 'data_model': 'washer_dryer_model', + 'data_model': 'dryer', 'model_number': '12345', }), }), diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 95fca331707..40c485a5b9f 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -66,7 +66,7 @@ async def test_dryer_sensor_values( await init_integration(hass) - entity_id = f"sensor.washerdryer_{MOCK_SAID4}_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID4}_none" mock_instance = mock_sensor2_api entry = entity_registry.async_get(entity_id) assert entry @@ -130,7 +130,7 @@ async def test_washer_sensor_values( ) await hass.async_block_till_done() - entity_id = f"sensor.washerdryer_{MOCK_SAID3}_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID3}_none" mock_instance = mock_sensor1_api entry = entity_registry.async_get(entity_id) assert entry From bd1c66984f52f5eb2370b8f2fee2f093008829cd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Apr 2025 20:23:03 +0200 Subject: [PATCH 045/217] Sentence-case "Heat pump" / "High demand" states in `water_heater` (#142012) --- homeassistant/components/water_heater/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 07e132a0b5b..9cc3a84c3cd 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -14,8 +14,8 @@ "eco": "Eco", "electric": "Electric", "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", + "high_demand": "High demand", + "heat_pump": "Heat pump", "performance": "Performance" }, "state_attributes": { From 91c53e9c523aeac8e2924f23da407f7307846e33 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 1 Apr 2025 14:27:06 -0400 Subject: [PATCH 046/217] Fix data in old SkyConnect integration config entries or delete them (#141959) * Delete old SkyConnect integration config entries * Try migrating, if possible * Do not delete config entries, log a failure --- .../homeassistant_sky_connect/__init__.py | 60 +++++++++- .../homeassistant_sky_connect/config_flow.py | 2 +- .../homeassistant_sky_connect/test_init.py | 111 +++++++++++++++++- 3 files changed, 166 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 1770e902b0f..212826687e1 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -6,14 +6,29 @@ import logging import os.path from homeassistant.components.homeassistant_hardware.util import guess_firmware_info -from homeassistant.components.usb import USBDevice, async_register_port_event_callback +from homeassistant.components.usb import ( + USBDevice, + async_register_port_event_callback, + scan_serial_ports, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DESCRIPTION, DEVICE, DOMAIN, FIRMWARE, FIRMWARE_VERSION, PRODUCT +from .const import ( + DESCRIPTION, + DEVICE, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) _LOGGER = logging.getLogger(__name__) @@ -73,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> """Migrate old entry.""" _LOGGER.debug( - "Migrating from version %s:%s", config_entry.version, config_entry.minor_version + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version ) if config_entry.version == 1: @@ -108,6 +123,43 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=3, ) + if config_entry.minor_version == 3: + # Old SkyConnect config entries were missing keys + if any( + key not in config_entry.data + for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER) + ): + serial_ports = await hass.async_add_executor_job(scan_serial_ports) + serial_ports_info = {port.device: port for port in serial_ports} + device = config_entry.data[DEVICE] + + if not (usb_info := serial_ports_info.get(device)): + raise HomeAssistantError( + f"USB device {device} is missing, cannot migrate" + ) + + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + VID: usb_info.vid, + PID: usb_info.pid, + MANUFACTURER: usb_info.manufacturer, + PRODUCT: usb_info.description, + DESCRIPTION: usb_info.description, + SERIAL_NUMBER: usb_info.serial_number, + }, + version=1, + minor_version=4, + ) + else: + # Existing entries are migrated by just incrementing the version + hass.config_entries.async_update_entry( + config_entry, + version=1, + minor_version=4, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index d28d74a681c..eb5ea214b3e 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -81,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow( """Handle a config flow for Home Assistant SkyConnect.""" VERSION = 1 - MINOR_VERSION = 3 + MINOR_VERSION = 4 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index f38ac158e71..f027a6d2fb8 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -9,7 +9,15 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) -from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.homeassistant_sky_connect.const import ( + DESCRIPTION, + DOMAIN, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) from homeassistant.components.usb import USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED @@ -57,7 +65,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.version == 1 - assert config_entry.minor_version == 3 + assert config_entry.minor_version == 4 assert config_entry.data == { "description": "SkyConnect v1.0", "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", @@ -187,3 +195,102 @@ async def test_usb_device_reactivity(hass: HomeAssistant) -> None: # The integration has reloaded and is now in a failed state await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None: + """Test fixing/deleting config entries with bad data.""" + + # Newly-added ZBT-1 + new_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9e2adbd75b8beb119fe564a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "9e2adbd75b8beb119fe564a0f320645d", + "manufacturer": "Nabu Casa", + "product": "SkyConnect v1.0", + "firmware": "ezsp", + "firmware_version": "7.4.4.0 (build 123)", + }, + version=1, + minor_version=3, + ) + + new_entry.add_to_hass(hass) + + # Old config entry, without firmware info + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-3c0ed67c628beb11b1cd64a0f320645d", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_3c0ed67c628beb11b1cd64a0f320645d-if00-port0", + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "description": "SkyConnect v1.0", + }, + version=1, + minor_version=1, + ) + + old_entry.add_to_hass(hass) + + # Bad config entry, missing most keys + bad_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-9f6c4bba657cc9a4f0cea48bc5948562", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9f6c4bba657cc9a4f0cea48bc5948562-if00-port0", + }, + version=1, + minor_version=2, + ) + + bad_entry.add_to_hass(hass) + + # Bad config entry, missing most keys, but fixable since the device is present + fixable_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id-4f5f3b26d59f8714a78b599690741999", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + }, + version=1, + minor_version=2, + ) + + fixable_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_sky_connect.scan_serial_ports", + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", + vid="10C4", + pid="EA60", + serial_number="4f5f3b26d59f8714a78b599690741999", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ) + ], + ): + await async_setup_component(hass, "homeassistant_sky_connect", {}) + + assert hass.config_entries.async_get_entry(new_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(old_entry.entry_id) is not None + assert hass.config_entries.async_get_entry(fixable_entry.entry_id) is not None + + updated_entry = hass.config_entries.async_get_entry(fixable_entry.entry_id) + assert updated_entry is not None + assert updated_entry.data[VID] == "10C4" + assert updated_entry.data[PID] == "EA60" + assert updated_entry.data[SERIAL_NUMBER] == "4f5f3b26d59f8714a78b599690741999" + assert updated_entry.data[MANUFACTURER] == "Nabu Casa" + assert updated_entry.data[PRODUCT] == "SkyConnect v1.0" + assert updated_entry.data[DESCRIPTION] == "SkyConnect v1.0" + + untouched_bad_entry = hass.config_entries.async_get_entry(bad_entry.entry_id) + assert untouched_bad_entry.minor_version == 3 From e7fadcda7b6670d6568eb91ac4a03f4e34488002 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 1 Apr 2025 20:27:34 +0200 Subject: [PATCH 047/217] Fix train to for multiple stations in Trafikverket Train (#142016) --- homeassistant/components/trafikverket_train/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index eb0a4a45791..fb39e14815e 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -266,7 +266,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_API_KEY: api_key, CONF_FROM: train_from, - CONF_TO: user_input[CONF_TO], + CONF_TO: train_to, CONF_TIME: train_time, CONF_WEEKDAY: train_days, CONF_FILTER_PRODUCT: filter_product, From 177fff3ff05a80c78e8e5d3eee891149af21dc09 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 1 Apr 2025 20:28:11 +0200 Subject: [PATCH 048/217] Add type hint on inherrited attribute _message_callback for MQTT mixin classes (#142011) --- homeassistant/components/mqtt/entity.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 8446f9041c9..2fe801b6a01 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -399,6 +399,9 @@ class MqttAttributesMixin(Entity): _attributes_extra_blocked: frozenset[str] = frozenset() _attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" @@ -433,7 +436,7 @@ class MqttAttributesMixin(Entity): CONF_JSON_ATTRS_TOPIC: { "topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._attributes_message_received, {"_attr_extra_state_attributes"}, ), @@ -482,6 +485,10 @@ class MqttAttributesMixin(Entity): class MqttAvailabilityMixin(Entity): """Mixin used for platforms that report availability.""" + _message_callback: Callable[ + [MessageCallbackType, set[str] | None, ReceiveMessage], None + ] + def __init__(self, config: ConfigType) -> None: """Initialize the availability mixin.""" self._availability_sub_state: dict[str, EntitySubscription] = {} @@ -547,7 +554,7 @@ class MqttAvailabilityMixin(Entity): f"availability_{topic}": { "topic": topic, "msg_callback": partial( - self._message_callback, # type: ignore[attr-defined] + self._message_callback, self._availability_message_received, {"available"}, ), From 74c2060c49494f6beb56643d4f01e1426dfa9b16 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 1 Apr 2025 16:39:25 -0400 Subject: [PATCH 049/217] Skip firmware config flow confirmation if the hardware is in use (#142017) * Auto-confirm the discovery if we detect that the device is already in use * Add a unit test --- .../firmware_config_flow.py | 11 ++++++++ .../test_config_flow.py | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 83031587712..1b4840e5a98 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -33,6 +33,7 @@ from .util import ( OwningIntegration, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, + guess_firmware_info, guess_hardware_owners, probe_silabs_firmware_info, ) @@ -511,6 +512,16 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm a discovery.""" + assert self._device is not None + fw_info = await guess_firmware_info(self.hass, self._device) + + # If our guess for the firmware type is actually running, we can save the user + # an unnecessary confirmation and silently confirm the flow + for owner in fw_info.owners: + if await owner.is_running(self.hass): + self._probed_firmware_info = fw_info + return self._async_flow_finished() + return await self.async_step_pick_firmware() diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 9b7ae3e6f63..2d5067bea3e 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -381,6 +381,32 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["step_id"] == "confirm_zigbee" +async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: + """Test the config flow skips the confirmation step the hardware is already used.""" + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_firmware_info", + return_value=FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[Mock(is_running=AsyncMock(return_value=True))], + source="guess", + ), + ): + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + # There are no steps, the config entry is automatically created + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.data == { + "firmware": "ezsp", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } + + async def test_config_flow_thread(hass: HomeAssistant) -> None: """Test the config flow.""" result = await hass.config_entries.flow.async_init( From 6a012498a5d6d815571abbafb5a83076a1b0267d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 1 Apr 2025 18:43:01 -0400 Subject: [PATCH 050/217] Fix entity names for HA hardware firmware update entities (#142029) * Fix entity names for HA hardware firmware update entities * Fix unit tests --- .../components/homeassistant_hardware/update.py | 7 +------ .../components/homeassistant_sky_connect/update.py | 1 - .../components/homeassistant_yellow/strings.json | 2 +- .../components/homeassistant_yellow/update.py | 10 +++++++--- tests/components/homeassistant_hardware/test_update.py | 9 ++++++++- tests/components/homeassistant_yellow/test_update.py | 2 +- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index 960facc81f8..1b0f15ca021 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -95,8 +95,7 @@ class BaseFirmwareUpdateEntity( _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS ) - # Until this entity can be associated with a device, we must manually name it - _attr_has_entity_name = False + _attr_has_entity_name = True def __init__( self, @@ -195,10 +194,6 @@ class BaseFirmwareUpdateEntity( def _update_attributes(self) -> None: """Recompute the attributes of the entity.""" - - # This entity is not currently associated with a device so we must manually - # give it a name - self._attr_name = f"{self._config_entry.title} Update" self._attr_title = self.entity_description.firmware_name or "Unknown" if ( diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index 5eaa1e220be..74c28b37eaf 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -168,7 +168,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """SkyConnect firmware update entity.""" bootloader_reset_type = None - _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index ddff5fd9b6d..41c1438b234 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -152,7 +152,7 @@ }, "entity": { "update": { - "firmware": { + "radio_firmware": { "name": "Radio firmware" } } diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index 94989d5c6b6..9531bd456cb 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -44,6 +44,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ ] = { ApplicationType.EZSP: FirmwareUpdateEntityDescription( key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -55,6 +56,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ ), ApplicationType.SPINEL: FirmwareUpdateEntityDescription( key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -65,7 +67,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ firmware_name="OpenThread RCP", ), ApplicationType.CPC: FirmwareUpdateEntityDescription( - key="firmware", + key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -76,7 +79,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ firmware_name="Multiprotocol", ), ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription( - key="firmware", + key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -88,6 +92,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ ), None: FirmwareUpdateEntityDescription( key="radio_firmware", + translation_key="radio_firmware", display_precision=0, device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, @@ -168,7 +173,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Yellow firmware update entity.""" bootloader_reset_type = "yellow" # Triggers a GPIO reset - _attr_has_entity_name = True def __init__( self, diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 0c351141e12..23d1e546791 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -43,6 +43,7 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -61,7 +62,7 @@ from tests.common import ( TEST_DOMAIN = "test" TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345" TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" -TEST_UPDATE_ENTITY_ID = "update.test_firmware" +TEST_UPDATE_ENTITY_ID = "update.mock_name_firmware" TEST_MANIFEST = FirmwareManifest( url=URL("https://example.org/firmware"), html_url=URL("https://example.org/release_notes"), @@ -205,6 +206,12 @@ class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): """Initialize the mock SkyConnect firmware update entity.""" super().__init__(device, config_entry, update_coordinator, entity_description) self._attr_unique_id = self.entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(TEST_DOMAIN, "yellow")}, + name="Mock Name", + model="Mock Model", + manufacturer="Mock Manufacturer", + ) # Use the cached firmware info if it exists if self._config_entry.data["firmware"] is not None: diff --git a/tests/components/homeassistant_yellow/test_update.py b/tests/components/homeassistant_yellow/test_update.py index 2cc7b51836c..66404dc2176 100644 --- a/tests/components/homeassistant_yellow/test_update.py +++ b/tests/components/homeassistant_yellow/test_update.py @@ -17,7 +17,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -UPDATE_ENTITY_ID = "update.home_assistant_yellow_firmware" +UPDATE_ENTITY_ID = "update.home_assistant_yellow_radio_firmware" async def test_yellow_update_entity(hass: HomeAssistant) -> None: From 1040fe50ec59c47201de7b36f261d7428cc60075 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Apr 2025 19:43:43 -1000 Subject: [PATCH 051/217] Bump aiohttp to 3.11.16 (#142034) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.15...v3.11.16 --- 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 291d47ec4cf..98e0de35a1e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.15 +aiohttp==3.11.16 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index b5ba5a0efd3..8d81bf7ff03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.15", + "aiohttp==3.11.16", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 0ef5062201a..f5e475bebce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.15 +aiohttp==3.11.16 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 253293c986de7e0d154ff6234ba1f7605ac564af Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 2 Apr 2025 07:45:17 +0200 Subject: [PATCH 052/217] Bump ZHA to 0.0.55 (#142031) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4daa2f2aa40..1c2d6556271 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.54"], + "requirements": ["zha==0.0.55"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index b906e92998c..5e13aa372d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3152,7 +3152,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.54 +zha==0.0.55 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff4f0e15af1..3c42737858e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2542,7 +2542,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.54 +zha==0.0.55 # homeassistant.components.zwave_js zwave-js-server-python==0.62.0 From 2305cb0131bb8a997199cfaa09f418d817ad39b0 Mon Sep 17 00:00:00 2001 From: Tomek Wasilczyk Date: Wed, 2 Apr 2025 00:07:36 -0700 Subject: [PATCH 053/217] Fix warning about unfinished oauth tasks on shutdown (#141969) * Don't wait for OAuth token task on shutdown To reproduce the warning: 1. Start authentication with integration using OAuth (e.g. SmartThings) 2. When redirected to external login site, just close the page 3. Settings -> Restart Home Assistant * Clarify comment --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/cloud/account_link.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 851d658f8e0..3c3d944d479 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement flow_id=flow_id, user_input=tokens ) - self.hass.async_create_task(await_tokens()) + # It's a background task because it should be cancelled on shutdown and there's nothing else + # we can do in such case. There's also no need to wait for this during setup. + self.hass.async_create_background_task( + await_tokens(), name="Awaiting OAuth tokens" + ) return authorize_url From bb7e1d472380b3422787d5ac92d9fea0890cfa05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Apr 2025 21:09:39 -1000 Subject: [PATCH 054/217] Reduce overhead to run headers middleware (#142032) Instead of having to itererate a dict, update the headers multidict using a pre-build CIMultiDict which has an internal fast path --- homeassistant/components/http/headers.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py index ebc0594e15a..fdb325c7b74 100644 --- a/homeassistant/components/http/headers.py +++ b/homeassistant/components/http/headers.py @@ -3,25 +3,34 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from typing import Final +from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware from aiohttp.web_exceptions import HTTPException +from multidict import CIMultiDict, istr from homeassistant.core import callback +REFERRER_POLICY: Final[istr] = istr("Referrer-Policy") +X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options") +X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options") + @callback def setup_headers(app: Application, use_x_frame_options: bool) -> None: """Create headers middleware for the app.""" - added_headers = { - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "Server": "", # Empty server header, to prevent aiohttp of setting one. - } + added_headers = CIMultiDict( + { + REFERRER_POLICY: "no-referrer", + X_CONTENT_TYPE_OPTIONS: "nosniff", + hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one. + } + ) if use_x_frame_options: - added_headers["X-Frame-Options"] = "SAMEORIGIN" + added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN" @middleware async def headers_middleware( From c35ec1f12bbe18fd7120d57a0448071366e5b6e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:19:06 +0200 Subject: [PATCH 055/217] Bump actions/dependency-review-action from 4.5.0 to 4.6.0 (#142042) Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/actions/dependency-review-action/releases) - [Commits](https://github.com/actions/dependency-review-action/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: actions/dependency-review-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a843133f1a5..6fc1fdbca1c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -653,7 +653,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Dependency review - uses: actions/dependency-review-action@v4.5.0 + uses: actions/dependency-review-action@v4.6.0 with: license-check: false # We use our own license audit checks From 6b45b0f522a071354824c0bee9b9ac2b09e1f12f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 1 Apr 2025 22:37:27 -1000 Subject: [PATCH 056/217] Bump bluetooth-data-tools to 1.26.5 (#142045) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.26.1...v1.26.5 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e4257221374..1b2b0e7267b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.1", + "bluetooth-data-tools==1.26.5", "dbus-fast==2.43.0", "habluetooth==3.37.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 1896f2109a7..764345710dd 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.26.5", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 270495c8770..b88ef3f029a 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.1", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.26.5", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 810fce41e05..df24f536527 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.1"] + "requirements": ["bluetooth-data-tools==1.26.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98e0de35a1e..28ff8861052 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.26.5 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 5e13aa372d7..fbf0f07ebf6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -639,7 +639,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.26.5 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c42737858e..5da867578fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.1 +bluetooth-data-tools==1.26.5 # homeassistant.components.bond bond-async==0.2.1 From e02a6f2f195621aaaef100e1c2f5d0a326182c0c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 11:00:13 +0200 Subject: [PATCH 057/217] Convert alexa test fixtures to async (#142054) --- .../components/alexa/test_flash_briefings.py | 61 +++---- tests/components/alexa/test_intent.py | 167 +++++++++--------- 2 files changed, 108 insertions(+), 120 deletions(-) diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index e76ed4ba6d0..c0f206ee4e2 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop import datetime from http import HTTPStatus @@ -24,13 +23,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -38,38 +35,36 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": { - "flash_briefings": { - "password": "pass/abc", - "weather": [ - { - "title": "Weekly forecast", - "text": "This week it will be sunny.", - }, - { - "title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit.", - }, - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid", + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "password": "pass/abc", + "weather": [ + { + "title": "Weekly forecast", + "text": "This week it will be sunny.", }, - } - }, + { + "title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit.", + }, + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid", + }, + } }, - ) + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _flash_briefing_req(client, briefing_id, password="pass%2Fabc"): diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index b82048dca9b..9c9a292c456 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -1,6 +1,5 @@ """The tests for the Alexa component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -30,13 +29,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client( - event_loop: AbstractEventLoop, +async def alexa_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Initialize a Home Assistant server for testing this module.""" - loop = event_loop @callback def mock_service(call): @@ -44,96 +41,92 @@ def alexa_client( hass.services.async_register("test", "alexa", mock_service) - assert loop.run_until_complete( - async_setup_component( - hass, - alexa.DOMAIN, - { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "alexa": {}, - }, - ) + assert await async_setup_component( + hass, + alexa.DOMAIN, + { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": {}, + }, ) - assert loop.run_until_complete( - async_setup_component( - hass, - "intent_script", - { - "intent_script": { - "WhereAreWeIntent": { - "speech": { - "type": "plain", - "text": """ - {%- if is_state("device_tracker.paulus", "home") - and is_state("device_tracker.anne_therese", - "home") -%} - You are both home, you silly - {%- else -%} - Anne Therese is at {{ - states("device_tracker.anne_therese") - }} and Paulus is at {{ - states("device_tracker.paulus") - }} - {% endif %} - """, - } + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "WhereAreWeIntent": { + "speech": { + "type": "plain", + "text": """ + {%- if is_state("device_tracker.paulus", "home") + and is_state("device_tracker.anne_therese", + "home") -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ + states("device_tracker.anne_therese") + }} and Paulus is at {{ + states("device_tracker.paulus") + }} + {% endif %} + """, + } + }, + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign }}.", + } + }, + "GetZodiacHoroscopeIDIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign_Id }}.", + } + }, + "AMAZON.PlaybackAction": { + "speech": { + "type": "plain", + "text": "Playing {{ object_byArtist_name }}.", + } + }, + "CallServiceIntent": { + "speech": { + "type": "plain", + "text": "Service called for {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign }}.", - } + "card": { + "type": "simple", + "title": "Card title for {{ ZodiacSign }}", + "content": "Card content: {{ ZodiacSign }}", }, - "GetZodiacHoroscopeIDIntent": { - "speech": { - "type": "plain", - "text": "You told us your sign is {{ ZodiacSign_Id }}.", - } + "action": { + "service": "test.alexa", + "data_template": {"hello": "{{ ZodiacSign }}"}, + "entity_id": "switch.test", }, - "AMAZON.PlaybackAction": { - "speech": { - "type": "plain", - "text": "Playing {{ object_byArtist_name }}.", - } + }, + APPLICATION_ID: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", + } + }, + APPLICATION_ID_SESSION_OPEN: { + "speech": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - "CallServiceIntent": { - "speech": { - "type": "plain", - "text": "Service called for {{ ZodiacSign }}", - }, - "card": { - "type": "simple", - "title": "Card title for {{ ZodiacSign }}", - "content": "Card content: {{ ZodiacSign }}", - }, - "action": { - "service": "test.alexa", - "data_template": {"hello": "{{ ZodiacSign }}"}, - "entity_id": "switch.test", - }, + "reprompt": { + "type": "plain", + "text": "LaunchRequest has been received.", }, - APPLICATION_ID: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - } - }, - APPLICATION_ID_SESSION_OPEN: { - "speech": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - "reprompt": { - "type": "plain", - "text": "LaunchRequest has been received.", - }, - }, - } - }, - ) + }, + } + }, ) - return loop.run_until_complete(hass_client()) + return await hass_client() def _intent_req(client, data=None): From 8432b6a7908cd8ce168215dfa19e0cf57491b7dc Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 2 Apr 2025 11:48:27 +0200 Subject: [PATCH 058/217] Bump deebot-client to 12.5.0 (#142046) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index acb5b620719..ad8b3ea70a5 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fbf0f07ebf6..ccd12e70731 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -758,7 +758,7 @@ debugpy==1.8.13 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.4.0 +deebot-client==12.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5da867578fe..25fa47b9413 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -649,7 +649,7 @@ dbus-fast==2.43.0 debugpy==1.8.13 # homeassistant.components.ecovacs -deebot-client==12.4.0 +deebot-client==12.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 36857b4b207fd2b4dbc0b564d6d775b673492c22 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 2 Apr 2025 06:38:48 -0400 Subject: [PATCH 059/217] Fix weather templates using new style configuration (#136677) --- homeassistant/components/template/weather.py | 59 +++++++++++++++---- tests/components/template/test_weather.py | 62 ++++++++++++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 7f597f1d9a8..86bab6f5ad1 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -135,6 +135,33 @@ WEATHER_SCHEMA = vol.Schema( PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the weather entities.""" + entities = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + + entities.append( + WeatherTemplate( + hass, + entity_conf, + unique_id, + ) + ) + + async_add_entities(entities) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -142,24 +169,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - if discovery_info and "coordinator" in discovery_info: + if discovery_info is None: + config = rewrite_common_legacy_to_modern_conf(hass, config) + unique_id = config.get(CONF_UNIQUE_ID) + async_add_entities( + [ + WeatherTemplate( + hass, + config, + unique_id, + ) + ] + ) + return + + if "coordinator" in discovery_info: async_add_entities( TriggerWeatherEntity(hass, discovery_info["coordinator"], config) for config in discovery_info["entities"] ) return - config = rewrite_common_legacy_to_modern_conf(hass, config) - unique_id = config.get(CONF_UNIQUE_ID) - - async_add_entities( - [ - WeatherTemplate( - hass, - config, - unique_id, - ) - ] + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], ) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 081028b6f5b..5db6a000ccc 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -928,3 +928,65 @@ async def test_trigger_entity_restore_state_fail( state = hass.states.get("weather.test") assert state.state == STATE_UNKNOWN assert state.attributes.get("temperature") is None + + +async def test_new_style_template_state_text(hass: HomeAssistant) -> None: + """Test the state text of a template.""" + assert await async_setup_component( + hass, + "weather", + { + "weather": [ + {"weather": {"platform": "demo"}}, + ] + }, + ) + assert await async_setup_component( + hass, + "template", + { + "template": { + "weather": { + "name": "test", + "attribution_template": "{{ states('sensor.attribution') }}", + "condition_template": "sunny", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + "pressure_template": "{{ states('sensor.pressure') }}", + "wind_speed_template": "{{ states('sensor.windspeed') }}", + "wind_bearing_template": "{{ states('sensor.windbearing') }}", + "ozone_template": "{{ states('sensor.ozone') }}", + "visibility_template": "{{ states('sensor.visibility') }}", + "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", + "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", + "dew_point_template": "{{ states('sensor.dew_point') }}", + "apparent_temperature_template": "{{ states('sensor.apparent_temperature') }}", + }, + }, + }, + ) + + for attr, v_attr, value in ( + ( + "sensor.attribution", + ATTR_ATTRIBUTION, + "The custom attribution", + ), + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ("sensor.pressure", ATTR_WEATHER_PRESSURE, 1000), + ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), + ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), + ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), + ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), + ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), + ("sensor.dew_point", ATTR_WEATHER_DEW_POINT, 2.2), + ("sensor.apparent_temperature", ATTR_WEATHER_APPARENT_TEMPERATURE, 25), + ): + hass.states.async_set(attr, value) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + assert state is not None + assert state.state == "sunny" + assert state.attributes.get(v_attr) == value From 795e01512afce931036702a5001f11a14f9daf8d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 13:49:12 +0200 Subject: [PATCH 060/217] Correct TodoItem docstrings (#142066) --- homeassistant/components/todo/__init__.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 937187c1c6f..c1c921343b8 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -219,18 +219,10 @@ class TodoItem: """A status or confirmation of the To-do item.""" due: datetime.date | datetime.datetime | None = None - """The date and time that a to-do is expected to be completed. - - This field may be a date or datetime depending whether the entity feature - DUE_DATE or DUE_DATETIME are set. - """ + """The date and time that a to-do is expected to be completed.""" description: str | None = None - """A more complete description of than that provided by the summary. - - This field may be set when TodoListEntityFeature.DESCRIPTION is supported by - the entity. - """ + """A more complete description than that provided by the summary.""" CACHED_PROPERTIES_WITH_ATTR_ = { From ca48b078586ec8711ecb1dd6d9a61a98f9789980 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 13:54:58 +0200 Subject: [PATCH 061/217] Add Eve brand (#142067) --- homeassistant/brands/eve.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/eve.json diff --git a/homeassistant/brands/eve.json b/homeassistant/brands/eve.json new file mode 100644 index 00000000000..f27c8b3d849 --- /dev/null +++ b/homeassistant/brands/eve.json @@ -0,0 +1,5 @@ +{ + "domain": "eve", + "name": "Eve", + "iot_standards": ["matter"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7bc76a28284..d0f0efe8ded 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1825,6 +1825,12 @@ } } }, + "eve": { + "name": "Eve", + "iot_standards": [ + "matter" + ] + }, "evergy": { "name": "Evergy", "integration_type": "virtual", From 93ea88f3ded293851856a657525468cf018c9426 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 13:56:23 +0200 Subject: [PATCH 062/217] Improve SmartThings sensor deprecation (#142070) * Improve SmartThings sensor deprecation * Improve SmartThings sensor deprecation * Improve SmartThings sensor deprecation --- .../components/smartthings/sensor.py | 147 ++-- .../components/smartthings/strings.json | 6 +- .../smartthings/snapshots/test_sensor.ambr | 831 ------------------ .../smartthings/test_binary_sensor.py | 2 - tests/components/smartthings/test_sensor.py | 187 +++- 5 files changed, 240 insertions(+), 933 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 424483d9617..346516be480 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -9,9 +9,8 @@ from typing import Any, cast from pysmartthings import Attribute, Capability, ComponentStatus, SmartThings, Status -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -33,16 +32,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.util import dt as dt_util from . import FullDevice, SmartThingsConfigEntry -from .const import DOMAIN, MAIN +from .const import MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity THERMOSTAT_CAPABILITIES = { Capability.TEMPERATURE_MEASUREMENT, @@ -1021,31 +1016,67 @@ async def async_setup_entry( ) -> None: """Add sensors for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSensor( - entry_data.client, - device, - description, - capability, - attribute, - ) - for device in entry_data.devices.values() - for capability, attributes in CAPABILITY_TO_SENSORS.items() - if capability in device.status[MAIN] - for attribute, descriptions in attributes.items() - for description in descriptions - if ( - not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list - ) - ) - and ( - not description.exists_fn - or description.exists_fn(device.status[MAIN][capability][attribute]) - ) - ) + entities = [] + + entity_registry = er.async_get(hass) + + for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks + for capability, attributes in CAPABILITY_TO_SENSORS.items(): + if capability in device.status[MAIN]: + for attribute, descriptions in attributes.items(): + for description in descriptions: + if ( + not description.capability_ignore_list + or not any( + all( + capability in device.status[MAIN] + for capability in capability_list + ) + for capability_list in description.capability_ignore_list + ) + ) and ( + not description.exists_fn + or description.exists_fn( + device.status[MAIN][capability][attribute] + ) + ): + if ( + description.deprecated + and ( + reason := description.deprecated( + device.status[MAIN] + ) + ) + is not None + ): + if deprecate_entity( + hass, + entity_registry, + SENSOR_DOMAIN, + f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}", + f"deprecated_{reason}", + ): + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + capability, + attribute, + ) + ) + continue + entities.append( + SmartThingsSensor( + entry_data.client, + device, + description, + capability, + attribute, + ) + ) + + async_add_entities(entities) class SmartThingsSensor(SmartThingsEntity, SensorEntity): @@ -1113,53 +1144,3 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): return [] return [option.lower() for option in options] return super().options - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - if ( - not self.entity_description.deprecated - or (reason := self.entity_description.deprecated(self.device.status[MAIN])) - is None - ): - return - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - if not automations and not scripts: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - items_list = [ - f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" - for integration, entities in ( - ("automation", automations), - ("script", scripts), - ) - for entity_id in entities - if (item := entity_reg.async_get(entity_id)) - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_{reason}_{self.entity_id}", - breaks_in_ha_version="2025.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_{reason}", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if ( - not self.entity_description.deprecated - or (reason := self.entity_description.deprecated(self.device.status[MAIN])) - is None - ): - return - async_delete_issue(self.hass, DOMAIN, f"deprecated_{reason}_{self.entity_id}") diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 1fbe535261e..43c6a22e66a 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -504,8 +504,12 @@ "description": "The switch `{entity}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." }, "deprecated_media_player": { + "title": "Media player sensors deprecated", + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards, templates to use the new media player entity and disable the entity to fix this issue." + }, + "deprecated_media_player_scripts": { "title": "Deprecated sensor detected in some automations or scripts", - "description": "The sensor `{entity}` is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to use the new media player entity and disable the entity to fix this issue." } } } diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 7be10ebac91..8ace345be18 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -8366,182 +8366,6 @@ 'state': '19.0', }) # --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'wifi', - 'bluetooth', - 'hdmi1', - 'hdmi2', - 'digital', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_mediaInputSource_inputSource_inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Media input source', - 'options': list([ - 'wifi', - 'bluetooth', - 'hdmi1', - 'hdmi2', - 'digital', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'wifi', - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'playing', - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.soundbar_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.soundbar_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- # name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8591,261 +8415,6 @@ 'state': '37', }) # --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.galaxy_home_mini_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaInputSource_inputSource_inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Galaxy Home Mini Media input source', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.galaxy_home_mini_media_playback_repeat', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Media playback repeat', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_repeat', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlaybackRepeat_playbackRepeatMode_playbackRepeatMode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Media playback repeat', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.galaxy_home_mini_media_playback_shuffle', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Media playback shuffle', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_shuffle', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlaybackShuffle_playbackShuffle_playbackShuffle', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Media playback shuffle', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'disabled', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Galaxy Home Mini Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.galaxy_home_mini_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Galaxy Home Mini Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.galaxy_home_mini_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '52', - }) -# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9184,119 +8753,6 @@ 'state': '20', }) # --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Elliots Rum Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'playing', - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.elliots_rum_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Elliots Rum Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.elliots_rum_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '15', - }) -# --- # name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9407,119 +8863,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Soundbar Living Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stopped', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.soundbar_living_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.soundbar_living_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9571,132 +8914,6 @@ 'state': '2', }) # --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media input source', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_input_source', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_mediaInputSource_inputSource_inputSource', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', - 'options': list([ - 'digitaltv', - 'hdmi1', - 'hdmi4', - 'hdmi4', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'hdmi1', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Media playback status', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'media_playback_status', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_mediaPlayback_playbackStatus_playbackStatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', - 'options': list([ - 'paused', - 'playing', - 'stopped', - 'fast_forwarding', - 'rewinding', - 'buffering', - ]), - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9791,54 +9008,6 @@ 'state': '', }) # --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.tv_samsung_8_series_49_volume', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13', - }) -# --- # name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index f7fcde3746f..9f9d8d66317 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -60,7 +60,6 @@ async def test_state_update( assert hass.states.get("binary_sensor.refrigerator_cooler_door").state == STATE_ON -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), [ @@ -167,7 +166,6 @@ async def test_create_issue_with_items( assert len(issue_registry.issues) == 0 -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), [ diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index fe112b3db6b..e90c177bd6d 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -9,8 +9,9 @@ from syrupy import SnapshotAssertion from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity -from homeassistant.components.smartthings.const import DOMAIN -from homeassistant.const import Platform +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.smartthings.const import DOMAIN, MAIN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component @@ -56,35 +57,80 @@ async def test_state_update( assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "entity_id", "translation_key"), + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + ), [ - ("hw_q80r_soundbar", "sensor.soundbar_volume", "media_player"), - ("hw_q80r_soundbar", "sensor.soundbar_media_playback_status", "media_player"), - ("hw_q80r_soundbar", "sensor.soundbar_media_input_source", "media_player"), ( - "im_speaker_ai_0001", - "sensor.galaxy_home_mini_media_playback_shuffle", + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", ), ( "im_speaker_ai_0001", - "sensor.galaxy_home_mini_media_playback_repeat", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", ), ], ) -async def test_create_issue( +async def test_create_issue_with_items( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, entity_id: str, - translation_key: str, + expected_state: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" - issue_id = f"deprecated_{translation_key}_{entity_id}" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) assert await async_setup_component( hass, @@ -123,19 +169,128 @@ async def test_create_issue( await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state == expected_state + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None - assert issue.translation_key == f"deprecated_{translation_key}" + assert issue.translation_key == f"deprecated_{issue_string}_scripts" assert issue.translation_placeholders == { - "entity": entity_id, + "entity_id": entity_id, + "entity_name": suggested_object_id, "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", } - await hass.config_entries.async_unload(mock_config_entry.entry_id) + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ( + "device_fixture", + "unique_id", + "suggested_object_id", + "issue_string", + "entity_id", + "expected_state", + ), + [ + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_PLAYBACK}_{Attribute.PLAYBACK_STATUS}_{Attribute.PLAYBACK_STATUS}", + "tv_samsung_8_series_49_media_playback_status", + "media_player", + "sensor.tv_samsung_8_series_49_media_playback_status", + STATE_UNKNOWN, + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.AUDIO_VOLUME}_{Attribute.VOLUME}_{Attribute.VOLUME}", + "tv_samsung_8_series_49_volume", + "media_player", + "sensor.tv_samsung_8_series_49_volume", + "13", + ), + ( + "vd_stv_2017_k", + f"4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_{MAIN}_{Capability.MEDIA_INPUT_SOURCE}_{Attribute.INPUT_SOURCE}_{Attribute.INPUT_SOURCE}", + "tv_samsung_8_series_49_media_input_source", + "media_player", + "sensor.tv_samsung_8_series_49_media_input_source", + "hdmi1", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_REPEAT}_{Attribute.PLAYBACK_REPEAT_MODE}_{Attribute.PLAYBACK_REPEAT_MODE}", + "galaxy_home_mini_media_playback_repeat", + "media_player", + "sensor.galaxy_home_mini_media_playback_repeat", + "off", + ), + ( + "im_speaker_ai_0001", + f"c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_{MAIN}_{Capability.MEDIA_PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}_{Attribute.PLAYBACK_SHUFFLE}", + "galaxy_home_mini_media_playback_shuffle", + "media_player", + "sensor.galaxy_home_mini_media_playback_shuffle", + "disabled", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + unique_id: str, + suggested_object_id: str, + issue_string: str, + entity_id: str, + expected_state: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id, + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state == expected_state + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present From 93162f6b659fbdb94fcdc7709816588e5da56d39 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:04:48 +0200 Subject: [PATCH 063/217] Mark Event and HassJob with @final (#142055) --- homeassistant/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index ec251832dba..b33e9496c7c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -38,6 +38,7 @@ from typing import ( TypedDict, TypeVar, cast, + final, overload, ) @@ -324,6 +325,7 @@ class HassJobType(enum.Enum): Executor = 3 +@final # Final to allow direct checking of the type instead of using isinstance class HassJob[**_P, _R_co]: """Represent a job to be run later. @@ -1317,6 +1319,7 @@ class EventOrigin(enum.Enum): return next((idx for idx, origin in enumerate(EventOrigin) if origin is self)) +@final # Final to allow direct checking of the type instead of using isinstance class Event(Generic[_DataT]): """Representation of an event within the bus.""" From dfd86d56ec632b66702893c7a1626ebd08c76dde Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:05:07 +0200 Subject: [PATCH 064/217] Convert test fixtures to async (#142052) --- tests/auth/providers/test_homeassistant.py | 8 ++-- tests/components/api/test_init.py | 6 +-- tests/components/cloud/conftest.py | 4 +- .../device_tracker/test_device_trigger.py | 26 +++++----- tests/components/duckdns/test_init.py | 10 ++-- tests/components/freedns/test_init.py | 24 +++++----- tests/components/frontend/test_storage.py | 4 +- tests/components/geo_location/test_trigger.py | 26 +++++----- .../google_assistant/test_google_assistant.py | 47 ++++++++----------- tests/components/hassio/conftest.py | 12 ++--- tests/components/http/test_cors.py | 7 +-- .../components/meraki/test_device_tracker.py | 35 ++++++-------- tests/components/namecheapdns/test_init.py | 12 ++--- tests/components/no_ip/test_init.py | 24 +++++----- tests/components/onboarding/test_views.py | 6 +-- .../owntracks/test_device_tracker.py | 24 +++++----- tests/components/owntracks/test_init.py | 6 +-- tests/components/person/conftest.py | 4 +- .../components/rss_feed_template/test_init.py | 9 ++-- tests/components/spaceapi/test_init.py | 8 ++-- tests/components/sun/test_trigger.py | 6 +-- tests/components/webhook/test_init.py | 8 ++-- tests/components/zone/test_trigger.py | 26 +++++----- tests/scripts/test_auth.py | 8 ++-- 24 files changed, 157 insertions(+), 193 deletions(-) diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index dd2ce65b480..42a5ba80643 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -19,18 +19,18 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def data(hass: HomeAssistant) -> hass_auth.Data: +async def data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() return data @pytest.fixture -def legacy_data(hass: HomeAssistant) -> hass_auth.Data: +async def legacy_data(hass: HomeAssistant) -> hass_auth.Data: """Create a loaded legacy data class.""" data = hass_auth.Data(hass) - hass.loop.run_until_complete(data.async_load()) + await data.async_load() data.is_legacy = True return data diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 6363304effc..26a3d7c7a8c 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -22,12 +22,12 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_api_client( +async def mock_api_client( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component and return admin API client.""" - hass.loop.run_until_complete(async_setup_component(hass, "api", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "api", {}) + return await hass_client() async def test_api_list_state_entities( diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 2d594fd9345..0e118f251de 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -218,9 +218,9 @@ def mock_user_data() -> Generator[MagicMock]: @pytest.fixture -def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: +async def mock_cloud_fixture(hass: HomeAssistant) -> CloudPreferences: """Fixture for cloud component.""" - hass.loop.run_until_complete(mock_cloud(hass)) + await mock_cloud(hass) return mock_cloud_prefs(hass, {}) diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index ebff89e1a15..860c470fc37 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -33,21 +33,19 @@ HOME_LONGITUDE = -117.237561 @pytest.fixture(autouse=True) -def setup_zone(hass: HomeAssistant) -> None: +async def setup_zone(hass: HomeAssistant) -> None: """Create test zone.""" - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": HOME_LATITUDE, - "longitude": HOME_LONGITUDE, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": HOME_LATITUDE, + "longitude": HOME_LONGITUDE, + "radius": 250, + } + }, ) diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index 313cc91aa18..7806d57e934 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -31,16 +31,16 @@ async def async_set_txt(hass: HomeAssistant, txt: str | None) -> None: @pytest.fixture -def setup_duckdns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_duckdns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up DuckDNS.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" ) - hass.loop.run_until_complete( - async_setup_component( - hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} - ) + await async_setup_component( + hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} ) diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py index d142fd767e1..eab0a1793ce 100644 --- a/tests/components/freedns/test_init.py +++ b/tests/components/freedns/test_init.py @@ -16,7 +16,9 @@ UPDATE_URL = freedns.UPDATE_URL @pytest.fixture -def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_freedns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up FreeDNS.""" params = {} params[ACCESS_TOKEN] = "" @@ -24,17 +26,15 @@ def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> N UPDATE_URL, params=params, text="Successfully updated 1 domains." ) - hass.loop.run_until_complete( - async_setup_component( - hass, - freedns.DOMAIN, - { - freedns.DOMAIN: { - "access_token": ACCESS_TOKEN, - "scan_interval": UPDATE_INTERVAL, - } - }, - ) + await async_setup_component( + hass, + freedns.DOMAIN, + { + freedns.DOMAIN: { + "access_token": ACCESS_TOKEN, + "scan_interval": UPDATE_INTERVAL, + } + }, ) diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index ce7f7aeb4a1..360ca151551 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -13,9 +13,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def setup_frontend(hass: HomeAssistant) -> None: +async def setup_frontend(hass: HomeAssistant) -> None: """Fixture to setup the frontend.""" - hass.loop.run_until_complete(async_setup_component(hass, "frontend", {})) + await async_setup_component(hass, "frontend", {}) async def test_get_user_data_empty( diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index 7673f357a08..0a9ad8a5b16 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -29,22 +29,20 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 035a8d151c4..26541d33613 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,6 +1,5 @@ """The tests for the Google Assistant component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json from unittest.mock import patch @@ -38,32 +37,28 @@ def auth_header(hass_access_token: str) -> dict[str, str]: @pytest.fixture -def assistant_client( - event_loop: AbstractEventLoop, +async def assistant_client( hass: core.HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> TestClient: """Create web client for the Google Assistant API.""" - loop = event_loop - loop.run_until_complete( - setup.async_setup_component( - hass, - "google_assistant", - { - "google_assistant": { - "project_id": PROJECT_ID, - "entity_config": { - "light.ceiling_lights": { - "aliases": ["top lights", "ceiling lights"], - "name": "Roof Lights", - } - }, - } - }, - ) + await setup.async_setup_component( + hass, + "google_assistant", + { + "google_assistant": { + "project_id": PROJECT_ID, + "entity_config": { + "light.ceiling_lights": { + "aliases": ["top lights", "ceiling lights"], + "name": "Roof Lights", + } + }, + } + }, ) - return loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() @pytest.fixture(autouse=True) @@ -87,16 +82,12 @@ async def wanted_platforms_only() -> None: @pytest.fixture -def hass_fixture( - event_loop: AbstractEventLoop, hass: core.HomeAssistant -) -> core.HomeAssistant: +async def hass_fixture(hass: core.HomeAssistant) -> core.HomeAssistant: """Set up a Home Assistant instance for these tests.""" - loop = event_loop - # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {})) + await setup.async_setup_component(hass, core.DOMAIN, {}) - loop.run_until_complete(setup.async_setup_component(hass, "demo", {})) + await setup.async_setup_component(hass, "demo", {}) return hass diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index c9fbf1a7c56..0c6e2158f3b 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -46,7 +46,7 @@ def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: @pytest.fixture -def hassio_stubs( +async def hassio_stubs( hassio_env: None, hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -75,27 +75,27 @@ def hassio_stubs( "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), ): - hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) + await async_setup_component(hass, "hassio", {}) return hass_api.call_args[0][1] @pytest.fixture -def hassio_client( +async def hassio_client( hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> TestClient: """Return a Hass.io HTTP client.""" - return hass.loop.run_until_complete(hass_client()) + return await hass_client() @pytest.fixture -def hassio_noauth_client( +async def hassio_noauth_client( hassio_stubs: RefreshToken, hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, ) -> TestClient: """Return a Hass.io HTTP client without auth.""" - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return await aiohttp_client(hass.http.app) @pytest.fixture diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index b637220ac6d..0581c7bac2a 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,6 +1,5 @@ """Test cors for the HTTP component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from pathlib import Path from unittest.mock import patch @@ -55,14 +54,12 @@ async def mock_handler(request): @pytest.fixture -def client( - event_loop: AbstractEventLoop, aiohttp_client: ClientSessionGenerator -) -> TestClient: +async def client(aiohttp_client: ClientSessionGenerator) -> TestClient: """Fixture to set up a web.Application.""" app = web.Application() setup_cors(app, [TRUSTED_ORIGIN]) app[KEY_ALLOW_CONFIGURED_CORS](app.router.add_get("/", mock_handler)) - return event_loop.run_until_complete(aiohttp_client(app)) + return await aiohttp_client(app) async def test_cors_requests(client) -> None: diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py index 139396a0689..c187ca8ce75 100644 --- a/tests/components/meraki/test_device_tracker.py +++ b/tests/components/meraki/test_device_tracker.py @@ -1,6 +1,5 @@ """The tests the for Meraki device tracker.""" -from asyncio import AbstractEventLoop from http import HTTPStatus import json @@ -22,31 +21,25 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def meraki_client( - event_loop: AbstractEventLoop, +async def meraki_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Meraki mock client.""" - loop = event_loop + assert await async_setup_component( + hass, + device_tracker.DOMAIN, + { + device_tracker.DOMAIN: { + CONF_PLATFORM: "meraki", + CONF_VALIDATOR: "validator", + CONF_SECRET: "secret", + } + }, + ) + await hass.async_block_till_done() - async def setup_and_wait(): - result = await async_setup_component( - hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "meraki", - CONF_VALIDATOR: "validator", - CONF_SECRET: "secret", - } - }, - ) - await hass.async_block_till_done() - return result - - assert loop.run_until_complete(setup_and_wait()) - return loop.run_until_complete(hass_client()) + return await hass_client() async def test_invalid_or_missing_data( diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py index 1d5b4ca5949..b7c1fe732c0 100644 --- a/tests/components/namecheapdns/test_init.py +++ b/tests/components/namecheapdns/test_init.py @@ -18,7 +18,7 @@ PASSWORD = "abcdefgh" @pytest.fixture -def setup_namecheapdns( +async def setup_namecheapdns( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Fixture that sets up NamecheapDNS.""" @@ -28,12 +28,10 @@ def setup_namecheapdns( text="0", ) - hass.loop.run_until_complete( - async_setup_component( - hass, - namecheapdns.DOMAIN, - {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, - ) + await async_setup_component( + hass, + namecheapdns.DOMAIN, + {"namecheapdns": {"host": HOST, "domain": DOMAIN, "password": PASSWORD}}, ) diff --git a/tests/components/no_ip/test_init.py b/tests/components/no_ip/test_init.py index e344b984e7d..4e9c5d67c74 100644 --- a/tests/components/no_ip/test_init.py +++ b/tests/components/no_ip/test_init.py @@ -22,22 +22,20 @@ USERNAME = "abc@123.com" @pytest.fixture -def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up NO-IP.""" aioclient_mock.get(UPDATE_URL, params={"hostname": DOMAIN}, text="good 0.0.0.0") - hass.loop.run_until_complete( - async_setup_component( - hass, - no_ip.DOMAIN, - { - no_ip.DOMAIN: { - "domain": DOMAIN, - "username": USERNAME, - "password": PASSWORD, - } - }, - ) + await async_setup_component( + hass, + no_ip.DOMAIN, + { + no_ip.DOMAIN: { + "domain": DOMAIN, + "username": USERNAME, + "password": PASSWORD, + } + }, ) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 509dece7dd0..9c5e93e49fe 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -36,11 +36,9 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def auth_active(hass: HomeAssistant) -> None: +async def auth_active(hass: HomeAssistant) -> None: """Ensure auth is always active.""" - hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) + await register_auth_provider(hass, {"type": "homeassistant"}) @pytest.fixture(name="rpi") diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 93f40d0ae3d..a659244e0a0 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -291,13 +291,13 @@ BAD_JSON_SUFFIX = "** and it ends here ^^" @pytest.fixture -def setup_comp( +async def setup_comp( hass: HomeAssistant, mock_device_tracker_conf: list[Device], mqtt_mock: MqttMockHAClient, ) -> None: """Initialize components.""" - hass.loop.run_until_complete(async_setup_component(hass, "device_tracker", {})) + await async_setup_component(hass, "device_tracker", {}) hass.states.async_set("zone.inner", "zoning", INNER_ZONE) @@ -320,7 +320,7 @@ async def setup_owntracks( @pytest.fixture -def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: +async def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: """Set up the mocked context.""" orig_context = owntracks.OwnTracksContext context = None @@ -331,16 +331,14 @@ def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory: context = orig_context(*args) return context - hass.loop.run_until_complete( - setup_owntracks( - hass, - { - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ["jon", "greg"], - }, - store_context, - ) + await setup_owntracks( + hass, + { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ["jon", "greg"], + }, + store_context, ) def get_context(): diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 5ef0efb0ab9..266a66b2760 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -43,7 +43,7 @@ def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: @pytest.fixture -def mock_client( +async def mock_client( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> TestClient: """Start the Home Assistant HTTP component.""" @@ -54,9 +54,9 @@ def mock_client( MockConfigEntry( domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"} ).add_to_hass(hass) - hass.loop.run_until_complete(async_setup_component(hass, "owntracks", {})) + await async_setup_component(hass, "owntracks", {}) - return hass.loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() async def test_handle_valid_message(mock_client) -> None: diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py index a6dc95ccc9e..2b1724f0c48 100644 --- a/tests/components/person/conftest.py +++ b/tests/components/person/conftest.py @@ -31,7 +31,7 @@ def storage_collection(hass: HomeAssistant) -> person.PersonStorageCollection: @pytest.fixture -def storage_setup( +async def storage_setup( hass: HomeAssistant, hass_storage: dict[str, Any], hass_admin_user: MockUser ) -> None: """Storage setup.""" @@ -49,4 +49,4 @@ def storage_setup( ] }, } - assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) + assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/rss_feed_template/test_init.py b/tests/components/rss_feed_template/test_init.py index 802fbb2244b..3b708b577af 100644 --- a/tests/components/rss_feed_template/test_init.py +++ b/tests/components/rss_feed_template/test_init.py @@ -1,6 +1,5 @@ """The tests for the rss_feed_api component.""" -from asyncio import AbstractEventLoop from http import HTTPStatus from aiohttp.test_utils import TestClient @@ -14,13 +13,11 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -def mock_http_client( - event_loop: AbstractEventLoop, +async def mock_http_client( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> TestClient: """Set up test fixture.""" - loop = event_loop config = { "rss_feed_template": { "testfeed": { @@ -35,8 +32,8 @@ def mock_http_client( } } - loop.run_until_complete(async_setup_component(hass, "rss_feed_template", config)) - return loop.run_until_complete(hass_client()) + await async_setup_component(hass, "rss_feed_template", config) + return await hass_client() async def test_get_nonexistant_feed(mock_http_client) -> None: diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 8c0e897947a..154ddb9253e 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -94,10 +94,12 @@ SENSOR_OUTPUT = { @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Start the Home Assistant HTTP component.""" with patch("homeassistant.components.spaceapi", return_value=True): - hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) + await async_setup_component(hass, "spaceapi", CONFIG) hass.states.async_set( "test.temp1", @@ -126,7 +128,7 @@ def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> Tes "test.hum1", 88, attributes={ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} ) - return hass.loop.run_until_complete(hass_client()) + return await hass_client() async def test_spaceapi_get(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index a7aeae25ac7..ec848c61338 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -27,12 +27,10 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - ) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) async def test_sunset_trigger( diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index 15ec1b15ee5..20fe5024962 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -17,10 +17,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: +async def mock_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Create http client for webhooks.""" - hass.loop.run_until_complete(async_setup_component(hass, "webhook", {})) - return hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "webhook", {}) + return await hass_client() async def test_unregistering_webhook(hass: HomeAssistant, mock_client) -> None: diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index a28b3c0592a..27276c6905f 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -17,22 +17,20 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" mock_component(hass, "group") - hass.loop.run_until_complete( - async_setup_component( - hass, - zone.DOMAIN, - { - "zone": { - "name": "test", - "latitude": 32.880837, - "longitude": -117.237561, - "radius": 250, - } - }, - ) + await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "test", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + } + }, ) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index e52a2cc6567..e9b6f4f718f 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -26,12 +26,10 @@ def reset_log_level() -> Generator[None]: @pytest.fixture -def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: +async def provider(hass: HomeAssistant) -> hass_auth.HassAuthProvider: """Home Assistant auth provider.""" - provider = hass.loop.run_until_complete( - register_auth_provider(hass, {"type": "homeassistant"}) - ) - hass.loop.run_until_complete(provider.async_initialize()) + provider = await register_auth_provider(hass, {"type": "homeassistant"}) + await provider.async_initialize() return provider From 8200c234dd9a3befa7f827d013dfde689ee2e8a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:05:23 +0200 Subject: [PATCH 065/217] Mark logbook.EventAsRow with @final (#142058) --- homeassistant/components/logbook/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 40b904c1279..f27a470a23d 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast +from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final from propcache.api import cached_property from sqlalchemy.engine.row import Row @@ -114,6 +114,7 @@ DATA_POS: Final = 11 CONTEXT_POS: Final = 12 +@final # Final to allow direct checking of the type instead of using isinstance class EventAsRow(NamedTuple): """Convert an event to a row. From 6fbee5c2e362702c20b33534b5911b3d26dbd004 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 14:06:01 +0200 Subject: [PATCH 066/217] Mark ReadOnlyDict with @final (#142059) --- homeassistant/util/read_only_dict.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/read_only_dict.py b/homeassistant/util/read_only_dict.py index 02befa78f60..3e4710cf220 100644 --- a/homeassistant/util/read_only_dict.py +++ b/homeassistant/util/read_only_dict.py @@ -1,7 +1,7 @@ """Read only dictionary.""" from copy import deepcopy -from typing import Any +from typing import Any, final def _readonly(*args: Any, **kwargs: Any) -> Any: @@ -9,6 +9,7 @@ def _readonly(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("Cannot modify ReadOnlyDict") +@final # Final to allow direct checking of the type instead of using isinstance class ReadOnlyDict[_KT, _VT](dict[_KT, _VT]): """Read only version of dict that is compatible with dict types.""" From feff5355c870ebf5d7894c0fd745ce07e3678332 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 15:05:43 +0200 Subject: [PATCH 067/217] Mark Integration with @final (#142057) --- homeassistant/loader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 20763dc7b30..e904fa4bdaf 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -18,7 +18,7 @@ import pathlib import sys import time from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast, final from awesomeversion import ( AwesomeVersion, @@ -646,6 +646,7 @@ def async_register_preload_platform(hass: HomeAssistant, platform_name: str) -> preload_platforms.append(platform_name) +@final # Final to allow direct checking of the type instead of using isinstance class Integration: """An integration in Home Assistant.""" From f8113ae80b4cd340f2770111eb284973c4e2be2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 2 Apr 2025 14:07:00 +0100 Subject: [PATCH 068/217] Allow excluding modules from noisy logs check (#142020) * Allow excluding modules from noisy logs check * Cache non-excluded modules; hardcode self module name; optimize call * Address review comments --- homeassistant/util/logging.py | 25 +++++++++++++++++++++---- tests/util/test_logging.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 1e516742bfe..d5dfab7da6c 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -29,16 +29,22 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): LOG_COUNTS_RESET_INTERVAL = 300 MAX_LOGS_COUNT = 200 + EXCLUDED_LOG_COUNT_MODULES = [ + "homeassistant.components.automation", + "homeassistant.components.script", + "homeassistant.setup", + "homeassistant.util.logging", + ] + _last_reset: float _log_counts: dict[str, int] - _warned_modules: set[str] def __init__( self, queue: SimpleQueue[logging.Handler], *handlers: logging.Handler ) -> None: """Initialize the handler.""" super().__init__(queue, *handlers) - self._warned_modules = set() + self._module_log_count_skip_flags: dict[str, bool] = {} self._reset_counters(time.time()) @override @@ -53,7 +59,11 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): self._reset_counters(record.created) module_name = record.name - if module_name == __name__ or module_name in self._warned_modules: + + if skip_flag := self._module_log_count_skip_flags.get(module_name): + return + + if skip_flag is None and self._update_skip_flags(module_name): return self._log_counts[module_name] += 1 @@ -66,13 +76,20 @@ class HomeAssistantQueueListener(logging.handlers.QueueListener): module_name, module_count, ) - self._warned_modules.add(module_name) + self._module_log_count_skip_flags[module_name] = True def _reset_counters(self, time_sec: float) -> None: _LOGGER.debug("Resetting log counters") self._last_reset = time_sec self._log_counts = defaultdict(int) + def _update_skip_flags(self, module_name: str) -> bool: + excluded = any( + module_name.startswith(prefix) for prefix in self.EXCLUDED_LOG_COUNT_MODULES + ) + self._module_log_count_skip_flags[module_name] = excluded + return excluded + class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index d213a68d7f2..ba473ee0c58 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -160,6 +160,10 @@ async def test_catch_log_exception_catches_and_logs() -> None: @patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +@patch( + "homeassistant.util.logging.HomeAssistantQueueListener.EXCLUDED_LOG_COUNT_MODULES", + ["excluded"], +) @pytest.mark.parametrize( ( "logger1_count", @@ -182,6 +186,7 @@ async def test_noisy_loggers( logging_util.async_activate_log_queue_handler(hass) logger1 = logging.getLogger("noisy1") logger2 = logging.getLogger("noisy2.module") + logger_excluded = logging.getLogger("excluded.module") for _ in range(logger1_count): logger1.info("This is a log") @@ -189,6 +194,9 @@ async def test_noisy_loggers( for _ in range(logger2_count): logger2.info("This is another log") + for _ in range(logging_util.HomeAssistantQueueListener.MAX_LOGS_COUNT + 1): + logger_excluded.info("This log should not trigger a warning") + await empty_log_queue() assert ( @@ -203,6 +211,33 @@ async def test_noisy_loggers( ) == logger2_expected_notices ) + # Ensure that the excluded module did not trigger a warning + assert ( + caplog.text.count("is logging too frequently") + == logger1_expected_notices + logger2_expected_notices + ) + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 1) +async def test_noisy_loggers_ignores_self( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the noisy loggers warning does not trigger a warning for its own module.""" + + logging_util.async_activate_log_queue_handler(hass) + logger1 = logging.getLogger("noisy_module1") + logger2 = logging.getLogger("noisy_module2") + logger3 = logging.getLogger("noisy_module3") + + logger1.info("This is a log") + logger2.info("This is a log") + logger3.info("This is a log") + + await empty_log_queue() + assert caplog.text.count("logging too frequently") == 3 # close the handler so the queue thread stops logging.root.handlers[0].close() From 833a8be2d14a4e3ab01b722966300d1c411f59c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 15:33:17 +0200 Subject: [PATCH 069/217] Improve SmartThings switch deprecation (#142072) --- .../components/smartthings/binary_sensor.py | 11 +- homeassistant/components/smartthings/const.py | 11 +- .../components/smartthings/strings.json | 16 +- .../components/smartthings/switch.py | 143 ++--- .../smartthings/snapshots/test_switch.ambr | 517 ------------------ tests/components/smartthings/test_switch.py | 200 ++++++- 6 files changed, 272 insertions(+), 626 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 75a080975ea..0fe0e7fe919 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity from .util import deprecate_entity @@ -127,14 +127,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.SWITCH, device_class=BinarySensorDeviceClass.POWER, is_on_key="on", - category={ - Category.CLOTHING_CARE_MACHINE, - Category.COOKTOP, - Category.DISHWASHER, - Category.DRYER, - Category.MICROWAVE, - Category.WASHER, - }, + category=INVALID_SWITCH_CATEGORIES, ) }, Capability.TAMPER_ALERT: { diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index a3ec9a38200..8f27b785688 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,6 +1,6 @@ """Constants used by the SmartThings component and platforms.""" -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Category DOMAIN = "smartthings" @@ -109,3 +109,12 @@ SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { Attribute.WASHER_MODE: Capability.WASHER_MODE, Attribute.WASHER_JOB_STATE: Capability.WASHER_OPERATING_STATE, } + +INVALID_SWITCH_CATEGORIES = { + Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, + Category.DRYER, + Category.WASHER, + Category.MICROWAVE, + Category.DISHWASHER, +} diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 43c6a22e66a..0a0b17c3b59 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -496,12 +496,20 @@ "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_appliance": { - "title": "Deprecated switch detected in some automations or scripts", - "description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts to fix this issue." + "title": "Appliance switch deprecated", + "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." + }, + "deprecated_switch_appliance_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_media_player": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch `{entity}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." + "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + }, + "deprecated_switch_media_player_scripts": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_media_player": { "title": "Media player sensors deprecated", @@ -509,7 +517,7 @@ }, "deprecated_media_player_scripts": { "title": "Deprecated sensor detected in some automations or scripts", - "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to use the new media player entity and disable the entity to fix this issue." + "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the entity to fix this issue." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index e5b74de3241..4e62957d3d4 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -5,23 +5,21 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from pysmartthings import Attribute, Capability, Category, Command, SmartThings +from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import FullDevice, SmartThingsConfigEntry -from .const import DOMAIN, MAIN +from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity +from .util import deprecate_entity CAPABILITIES = ( Capability.SWITCH_LEVEL, @@ -37,6 +35,12 @@ AC_CAPABILITIES = ( Capability.THERMOSTAT_COOLING_SETPOINT, ) +MEDIA_PLAYER_CAPABILITIES = ( + Capability.AUDIO_MUTE, + Capability.AUDIO_VOLUME, + Capability.MEDIA_PLAYBACK, +) + @dataclass(frozen=True, kw_only=True) class SmartThingsSwitchEntityDescription(SwitchEntityDescription): @@ -92,13 +96,6 @@ async def async_setup_entry( """Add switches for a config entry.""" entry_data = entry.runtime_data entities: list[SmartThingsEntity] = [ - SmartThingsSwitch(entry_data.client, device, SWITCH, Capability.SWITCH) - for device in entry_data.devices.values() - if Capability.SWITCH in device.status[MAIN] - and not any(capability in device.status[MAIN] for capability in CAPABILITIES) - and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) - ] - entities.extend( SmartThingsCommandSwitch( entry_data.client, device, @@ -108,7 +105,7 @@ async def async_setup_entry( for device in entry_data.devices.values() for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items() if capability in device.status[MAIN] - ) + ] entities.extend( SmartThingsSwitch( entry_data.client, @@ -129,6 +126,51 @@ async def async_setup_entry( ) ) ) + entity_registry = er.async_get(hass) + for device in entry_data.devices.values(): + if ( + Capability.SWITCH in device.status[MAIN] + and not any( + capability in device.status[MAIN] for capability in CAPABILITIES + ) + and not all( + capability in device.status[MAIN] for capability in AC_CAPABILITIES + ) + ): + media_player = all( + capability in device.status[MAIN] + for capability in MEDIA_PLAYER_CAPABILITIES + ) + appliance = ( + device.device.components[MAIN].manufacturer_category + in INVALID_SWITCH_CATEGORIES + ) + if media_player or appliance: + issue = "media_player" if media_player else "appliance" + if deprecate_entity( + hass, + entity_registry, + SWITCH_DOMAIN, + f"{device.device.device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + f"deprecated_switch_{issue}", + ): + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) + continue + entities.append( + SmartThingsSwitch( + entry_data.client, + device, + SWITCH, + Capability.SWITCH, + ) + ) async_add_entities(entities) @@ -136,7 +178,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" entity_description: SmartThingsSwitchEntityDescription - created_issue: bool = False def __init__( self, @@ -182,70 +223,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): == "on" ) - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - media_player = all( - capability in self.device.status[MAIN] - for capability in ( - Capability.AUDIO_MUTE, - Capability.AUDIO_VOLUME, - Capability.MEDIA_PLAYBACK, - ) - ) - if ( - self.entity_description != SWITCH - and self.device.device.components[MAIN].manufacturer_category - not in { - Category.CLOTHING_CARE_MACHINE, - Category.COOKTOP, - Category.DRYER, - Category.WASHER, - Category.MICROWAVE, - Category.DISHWASHER, - } - ) or (self.entity_description != SWITCH and not media_player): - return - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - if not automations and not scripts: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - items_list = [ - f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" - for integration, entities in ( - ("automation", automations), - ("script", scripts), - ) - for entity_id in entities - if (item := entity_reg.async_get(entity_id)) - ] - - identifier = "media_player" if media_player else "appliance" - - self.created_issue = True - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_switch_{self.entity_id}", - breaks_in_ha_version="2025.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_switch_{identifier}", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.created_issue: - return - async_delete_issue(self.hass, DOMAIN, f"deprecated_switch_{self.entity_id}") - class SmartThingsCommandSwitch(SmartThingsSwitch): """Define a SmartThings command switch.""" diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 8c95d2f20fc..d14d4d02aa4 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -46,100 +46,6 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ks_cooktop_31001][switch.induction_hob-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.induction_hob', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ks_cooktop_31001][switch.induction_hob-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Induction Hob', - }), - 'context': , - 'entity_id': 'switch.induction_hob', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.microwave', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave', - }), - 'context': , - 'entity_id': 'switch.microwave', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -281,147 +187,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.dishwasher', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher', - }), - 'context': , - 'entity_id': 'switch.dishwasher', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_wm_sc_000001][switch.airdresser-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.airdresser', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_sc_000001][switch.airdresser-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AirDresser', - }), - 'context': , - 'entity_id': 'switch.airdresser', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.dryer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer', - }), - 'context': , - 'entity_id': 'switch.dryer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -469,53 +234,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.seca_roupa', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Seca-Roupa', - }), - 'context': , - 'entity_id': 'switch.seca_roupa', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -563,100 +281,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.washer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][switch.washer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer', - }), - 'context': , - 'entity_id': 'switch.washer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.washing_machine', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washing Machine', - }), - 'context': , - 'entity_id': 'switch.washing_machine', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -751,53 +375,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[hw_q80r_soundbar][switch.soundbar-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.soundbar', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[hw_q80r_soundbar][switch.soundbar-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar', - }), - 'context': , - 'entity_id': 'switch.soundbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -939,53 +516,6 @@ 'state': 'on', }) # --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.soundbar_living', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living', - }), - 'context': , - 'entity_id': 'switch.soundbar_living', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1033,50 +563,3 @@ 'state': 'off', }) # --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - '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.tv_samsung_8_series_49', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49)', - }), - 'context': , - 'entity_id': 'switch.tv_samsung_8_series_49', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 2e360ff68e3..a47ecde7e0d 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -126,25 +126,86 @@ async def test_state_update( assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF -@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "entity_id", "translation_key"), + ("device_fixture", "device_id", "suggested_object_id", "issue_string"), [ - ("da_wm_wm_000001", "switch.washer", "deprecated_switch_appliance"), - ("da_wm_wd_000001", "switch.dryer", "deprecated_switch_appliance"), - ("hw_q80r_soundbar", "switch.soundbar", "deprecated_switch_media_player"), + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + ), ], ) -async def test_create_issue( +async def test_create_issue_with_items( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, - entity_id: str, - translation_key: str, + device_id: str, + suggested_object_id: str, + issue_string: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" - issue_id = f"deprecated_switch_{entity_id}" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) assert await async_setup_component( hass, @@ -183,19 +244,134 @@ async def test_create_issue( await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None - assert issue.translation_key == translation_key + assert issue.translation_key == f"deprecated_switch_{issue_string}_scripts" assert issue.translation_placeholders == { - "entity": entity_id, + "entity_id": entity_id, + "entity_name": suggested_object_id, "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", } - await hass.config_entries.async_unload(mock_config_entry.entry_id) + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("device_fixture", "device_id", "suggested_object_id", "issue_string"), + [ + ( + "da_ks_cooktop_31001", + "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "induction_hob", + "appliance", + ), + ( + "da_ks_microwave_0101x", + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "microwave", + "appliance", + ), + ( + "da_wm_dw_000001", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "dishwasher", + "appliance", + ), + ( + "da_wm_sc_000001", + "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "airdresser", + "appliance", + ), + ( + "da_wm_wd_000001", + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "dryer", + "appliance", + ), + ( + "da_wm_wm_000001", + "f984b91d-f250-9d42-3436-33f09a422a47", + "washer", + "appliance", + ), + ( + "hw_q80r_soundbar", + "afcf3b91-0000-1111-2222-ddff2a0a6577", + "soundbar", + "media_player", + ), + ( + "vd_network_audio_002s", + "0d94e5db-8501-2355-eb4f-214163702cac", + "soundbar_living", + "media_player", + ), + ( + "vd_stv_2017_k", + "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "tv_samsung_8_series_49", + "media_player", + ), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + device_id: str, + suggested_object_id: str, + issue_string: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"switch.{suggested_object_id}" + issue_id = f"deprecated_switch_{issue_string}_{entity_id}" + + entity_entry = entity_registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + suggested_object_id=suggested_object_id, + original_name=suggested_object_id, + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(entity_id).state in [STATE_OFF, STATE_ON] + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == f"deprecated_switch_{issue_string}" + assert issue.translation_placeholders == { + "entity_id": entity_id, + "entity_name": suggested_object_id, + } + + entity_registry.async_update_entity( + entity_entry.entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present From 4c44d2f4d96db79e8d98479e8926362cb600a090 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:33:41 -0400 Subject: [PATCH 070/217] Translation key for ZBT-1 integration failing due to disconnection (#142077) Translation key for device disconnected --- .../components/homeassistant_sky_connect/__init__.py | 5 ++++- .../components/homeassistant_sky_connect/strings.json | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 212826687e1..dfc129ddc75 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -71,7 +71,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Postpone loading the config entry if the device is missing device_path = entry.data[DEVICE] if not await hass.async_add_executor_job(os.path.exists, device_path): - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_disconnected", + ) await hass.config_entries.async_forward_entry_setups(entry, ["update"]) diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index a596b9846ce..a990f025e8d 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -195,5 +195,10 @@ "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" } + }, + "exceptions": { + "device_disconnected": { + "message": "The device is not plugged in" + } } } From 0871bf13a4b7cb2cf028b96219b1f49f4cf7abdc Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 2 Apr 2025 15:39:31 +0200 Subject: [PATCH 071/217] Deprecate None effect instead of breaking it for Hue (#142073) * Deprecate effect none instead of breaking it for Hue * add guard for unknown effect value * revert guard * Fix * Add test * Add test * Add test --------- Co-authored-by: Joostlek --- homeassistant/components/hue/strings.json | 6 ++++ homeassistant/components/hue/v2/light.py | 21 +++++++++++ tests/components/hue/test_light_v2.py | 44 +++++++++++++++++++++-- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 6d2e9054c6f..3326dd1043f 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -197,5 +197,11 @@ } } } + }, + "issues": { + "deprecated_effect_none": { + "title": "Light turned on with deprecated effect", + "description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect." + } } } diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 757b69c7b7b..8eb7ec8936e 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import color as color_util from ..bridge import HueBridge @@ -44,6 +45,9 @@ FALLBACK_MIN_KELVIN = 6500 FALLBACK_MAX_KELVIN = 2000 FALLBACK_KELVIN = 5800 # halfway +# HA 2025.4 replaced the deprecated effect "None" with HA default "off" +DEPRECATED_EFFECT_NONE = "None" + async def async_setup_entry( hass: HomeAssistant, @@ -233,6 +237,23 @@ class HueLight(HueBaseEntity, LightEntity): self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) + if effect_str == DEPRECATED_EFFECT_NONE: + # deprecated effect "None" is now "off" + effect_str = EFFECT_OFF + async_create_issue( + self.hass, + DOMAIN, + "deprecated_effect_none", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_effect_none", + ) + self.logger.warning( + "Detected deprecated effect 'None' in %s, use 'off' instead. " + "This will stop working in HA 2025.10", + self.entity_id, + ) if effect_str == EFFECT_OFF: # ignore effect if set to "off" and we have no effect active # the special effect "off" is only used to stop an active effect diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 3d323d4d31c..f4a6fcfba93 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -2,9 +2,14 @@ from unittest.mock import Mock -from homeassistant.components.light import ColorMode +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON 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.util.json import JsonArrayType from .conftest import setup_platform @@ -639,3 +644,38 @@ async def test_grouped_lights( mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] == "identify" ) + + +async def test_light_turn_on_service_deprecation( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + issue_registry: ir.IssueRegistry, +) -> None: + """Test calling the turn on service on a light.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + test_light_id = "light.hue_light_with_color_temperature_only" + + await setup_platform(hass, mock_bridge_v2, "light") + + event = { + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "type": "light", + "effects": {"status": "candle"}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + + # test disable effect + # it should send a request with effect set to "no_effect" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: test_light_id, + ATTR_EFFECT: "None", + }, + blocking=True, + ) + assert mock_bridge_v2.mock_requests[0]["json"]["effects"]["effect"] == "no_effect" From 6b34c38d21978e086356a0d897d4bb7eb8c2b72f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 2 Apr 2025 17:00:29 +0200 Subject: [PATCH 072/217] Fix state class for battery sensors in AVM Fritz!SmartHome (#142078) * set proper state class for battery sensor * fix tests --- homeassistant/components/fritzbox/sensor.py | 1 + tests/components/fritzbox/test_binary_sensor.py | 8 ++++++-- tests/components/fritzbox/test_climate.py | 8 ++++++-- tests/components/fritzbox/test_sensor.py | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index bed7004bd6a..801a3a67a6e 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -137,6 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, suitable=lambda device: device.battery_level is not None, native_value=lambda device: device.battery_level, diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 594ed14a7d1..d5b0b5d196b 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -11,7 +11,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, ) from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -71,7 +75,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.state == "23" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 7766d906f68..699a2b8c53e 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -37,7 +37,11 @@ from homeassistant.components.fritzbox.const import ( ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, @@ -99,7 +103,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.state == "23" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") assert state diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 67b2c3e8ab6..cb136eee993 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -71,7 +71,7 @@ async def test_setup( "23", f"{CONF_FAKE_NAME} Battery", PERCENTAGE, - None, + SensorStateClass.MEASUREMENT, EntityCategory.DIAGNOSTIC, ], ) From 2a66c03d73b428096a2dc441d503d2de303aab9b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 2 Apr 2025 17:15:36 +0200 Subject: [PATCH 073/217] Fix switch name Unknown in SmartThings (#142081) Fix switch name Unknown --- homeassistant/components/smartthings/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0a0b17c3b59..dfcaa094d1b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -497,19 +497,19 @@ }, "deprecated_switch_appliance": { "title": "Appliance switch deprecated", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue." }, "deprecated_switch_appliance_scripts": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_media_player": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_switch_media_player_scripts": { "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", - "description": "The switch {entity_name} (`{entity_id}`) is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." + "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue." }, "deprecated_media_player": { "title": "Media player sensors deprecated", From 30ea27d4a5a57ae28ea6c0209624bb8dac814970 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 17:33:36 +0200 Subject: [PATCH 074/217] Replace "to log into" with "to log in to" in `incomfort` (#142060) * Replace "to log into" with "to log in to" in `incomfort` Also fix one missing sentence-casing of "gateway". * Replace duplicate "data_description" strings with references --- homeassistant/components/incomfort/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 31fec77f455..6a07849b01d 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -10,8 +10,8 @@ }, "data_description": { "host": "Hostname or IP-address of the Intergas gateway.", - "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." + "username": "The username to log in to the gateway. This is `admin` in most cases.", + "password": "The password to log in to the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { @@ -22,8 +22,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." + "username": "[%key:component::incomfort::config::step::user::data_description::username%]", + "password": "[%key:component::incomfort::config::step::user::data_description::password%]" } }, "dhcp_confirm": { From a7be9e664356e6ee496c3cb6169dcf39f0964dec Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Apr 2025 19:17:51 +0200 Subject: [PATCH 075/217] Fix humidifier platform for Comelit (#141854) * Fix humidifier platform for Comelit * apply review comment --- homeassistant/components/comelit/humidifier.py | 6 +++++- homeassistant/components/comelit/strings.json | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index ad8f49ed5e2..d7b20f731a9 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -162,7 +162,7 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self.mode == HumidifierComelitMode.OFF: + if not self._attr_is_on: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="humidity_while_off", @@ -190,9 +190,13 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier await self.coordinator.api.set_humidity_status( self._device.index, self._set_command ) + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.OFF ) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index a738c837d1b..d4d0b8f670f 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -52,7 +52,9 @@ "rest": "Rest", "sabotated": "Sabotated" } - }, + } + }, + "humidifier": { "humidifier": { "name": "Humidifier" }, From 260121720945fdf1fc49dd287f28ead168a25223 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 19:50:55 +0200 Subject: [PATCH 076/217] Use common states for battery sensor in `withings` (#142043) Use common states for battery level in `withings` --- homeassistant/components/withings/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 775ef5cdaab..746fa244c8e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -313,9 +313,9 @@ "battery": { "name": "[%key:component::sensor::entity_component::battery::name%]", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } From 2876e5d0cd597bf6cdcb0078d5ae3f3aeb2557f0 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:29:27 -0700 Subject: [PATCH 077/217] Improve and add missing config flow strings in NUT (#142035) * Improve and add missing config descriptions * Fix string * Conform to Microsoft style guidelines on 'sign in' * Note username and password are optional --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nut/strings.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index a7231b22235..dc232cd28c6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -10,13 +10,16 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your NUT server." + "host": "The IP address or hostname of your NUT server.", + "port": "The network port of your NUT server. The NUT server's default port is '3493'.", + "username": "The username to sign in to your NUT server. The usernmae is optional.", + "password": "The password to sign in to your NUT server. The password is optional." } }, "ups": { - "title": "Choose the UPS to Monitor", + "title": "Choose the NUT server UPS to monitor", "data": { - "alias": "Alias" + "alias": "NUT server UPS name" } }, "reauth_confirm": { From 691cb378a07c32dd424b2d997fef463bcbb9d32b Mon Sep 17 00:00:00 2001 From: currand Date: Wed, 2 Apr 2025 16:29:40 -0400 Subject: [PATCH 078/217] Correctly support humidification and dehumidification in Nexia Thermostats (#139792) * Add set_dehumidify_setpoint service. Refactor set_humidify_setpoint. * Add closest_value function in utils * Refactor target humidity * Update tests for util.py * Refactor target humidity. Update tests. * Remove duplicate constant * Add humidify and dehumidfy sensors * Update sensor names * Remove clamping and commented code * Iplement suggestions from review * Switch order check order * remove closest_value() * Update strings for clarity/grammar * Update strings for grammar/clarity * tweaks --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nexia/climate.py | 54 +++++++++++++++++--- homeassistant/components/nexia/icons.json | 3 ++ homeassistant/components/nexia/sensor.py | 29 +++++++++++ homeassistant/components/nexia/services.yaml | 14 +++++ homeassistant/components/nexia/strings.json | 22 ++++++-- 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index e9de81cca7c..e9637a16ae0 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -53,13 +53,18 @@ PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode" SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint" +SERVICE_SET_DEHUMIDIFY_SETPOINT = "set_dehumidify_setpoint" SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode" SET_AIRCLEANER_SCHEMA: VolDictType = { vol.Required(ATTR_AIRCLEANER_MODE): cv.string, } -SET_HUMIDITY_SCHEMA: VolDictType = { +SET_HUMIDIFY_SCHEMA: VolDictType = { + vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=10, max=45)), +} + +SET_DEHUMIDIFY_SCHEMA: VolDictType = { vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=35, max=65)), } @@ -126,9 +131,14 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_SET_HUMIDIFY_SETPOINT, - SET_HUMIDITY_SCHEMA, + SET_HUMIDIFY_SCHEMA, f"async_{SERVICE_SET_HUMIDIFY_SETPOINT}", ) + platform.async_register_entity_service( + SERVICE_SET_DEHUMIDIFY_SETPOINT, + SET_DEHUMIDIFY_SCHEMA, + f"async_{SERVICE_SET_DEHUMIDIFY_SETPOINT}", + ) platform.async_register_entity_service( SERVICE_SET_AIRCLEANER_MODE, SET_AIRCLEANER_SCHEMA, @@ -224,20 +234,48 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): return self._zone.get_preset() async def async_set_humidity(self, humidity: int) -> None: - """Dehumidify target.""" - if self._thermostat.has_dehumidify_support(): - await self.async_set_dehumidify_setpoint(humidity) + """Set humidity targets. + + HA doesn't support separate humidify and dehumidify targets. + Set the target for the current mode if in [heat, cool] + otherwise set both targets to the clamped values. + """ + zone_current_mode = self._zone.get_current_mode() + if zone_current_mode == OPERATION_MODE_HEAT: + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + elif zone_current_mode == OPERATION_MODE_COOL: + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) else: - await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_humidify_support(): + await self.async_set_humidify_setpoint(humidity) + if self._thermostat.has_dehumidify_support(): + await self.async_set_dehumidify_setpoint(humidity) self._signal_thermostat_update() @property - def target_humidity(self): - """Humidity indoors setpoint.""" + def target_humidity(self) -> float | None: + """Humidity indoors setpoint. + + In systems that support both humidification and dehumidification, + two values for target exist. We must choose one to return. + + :return: The target humidity setpoint. + """ + + # If heat is on, always return humidify value first + if ( + self._has_humidify_support + and self._zone.get_current_mode() == OPERATION_MODE_HEAT + ): + return percent_conv(self._thermostat.get_humidify_setpoint()) + # Fall back to previous behavior of returning dehumidify value then humidify if self._has_dehumidify_support: return percent_conv(self._thermostat.get_dehumidify_setpoint()) if self._has_humidify_support: return percent_conv(self._thermostat.get_humidify_setpoint()) + return None @property diff --git a/homeassistant/components/nexia/icons.json b/homeassistant/components/nexia/icons.json index a2157f5c035..c9434a332df 100644 --- a/homeassistant/components/nexia/icons.json +++ b/homeassistant/components/nexia/icons.json @@ -26,6 +26,9 @@ "set_humidify_setpoint": { "service": "mdi:water-percent" }, + "set_dehumidify_setpoint": { + "service": "mdi:water-percent" + }, "set_hvac_run_mode": { "service": "mdi:hvac" } diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index 293a9308cb4..648b5dc3eeb 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -114,6 +114,35 @@ async def async_setup_entry( percent_conv, ) ) + # Heating Humidification Setpoint + if thermostat.has_humidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_humidify_setpoint", + "get_humidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) + + # Cooling Dehumidification Setpoint + if thermostat.has_dehumidify_support(): + entities.append( + NexiaThermostatSensor( + coordinator, + thermostat, + "get_dehumidify_setpoint", + "get_dehumidify_setpoint", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + SensorStateClass.MEASUREMENT, + percent_conv, + ) + ) # Zone Sensors for zone_id in thermostat.get_zone_ids(): diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml index ede1f311acf..d010676d14a 100644 --- a/homeassistant/components/nexia/services.yaml +++ b/homeassistant/components/nexia/services.yaml @@ -14,6 +14,20 @@ set_aircleaner_mode: - "quick" set_humidify_setpoint: + target: + entity: + integration: nexia + domain: climate + fields: + humidity: + required: true + selector: + number: + min: 10 + max: 45 + unit_of_measurement: "%" + +set_dehumidify_setpoint: target: entity: integration: nexia diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 43da2cf05c7..f6b08d5e8e5 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -53,6 +53,12 @@ }, "zone_setpoint_status": { "name": "Zone setpoint status" + }, + "get_humidify_setpoint": { + "name": "Heating humidify setpoint" + }, + "get_dehumidify_setpoint": { + "name": "Cooling dehumidify setpoint" } }, "switch": { @@ -76,12 +82,22 @@ } }, "set_humidify_setpoint": { - "name": "Set humidify set point", - "description": "Sets the target humidity.", + "name": "Set humidify setpoint", + "description": "Sets the target humidity for heating.", "fields": { "humidity": { "name": "Humidity", - "description": "The humidification setpoint." + "description": "The setpoint for humidification when heating." + } + } + }, + "set_dehumidify_setpoint": { + "name": "Set dehumidify setpoint", + "description": "Sets the target humidity for cooling.", + "fields": { + "humidity": { + "name": "Humidity", + "description": "The setpoint for dehumidification when cooling." } } }, From 17f6ded7b091643b92ab2d3c3ff690dd5dc3d97e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:32:24 +0200 Subject: [PATCH 079/217] Use common states for "Low"/"Medium"/"High" in `wyoming` (#142096) --- homeassistant/components/wyoming/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 4480b00d867..2578b0e5278 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -41,9 +41,9 @@ "name": "Noise suppression level", "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "max": "Max" } }, From f8a15c822872421bd189b4312d25a8298ddd24a4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:35:52 +0200 Subject: [PATCH 080/217] Use common states for "Low"/"Medium"/"High" in `matter` (#142095) --- homeassistant/components/matter/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c34666c03bb..81a9a4ba796 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -135,9 +135,9 @@ "state_attributes": { "preset_mode": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "auto": "Auto", "natural_wind": "Natural wind", "sleep_wind": "Sleep wind" @@ -189,9 +189,9 @@ "sensitivity_level": { "name": "Sensitivity", "state": { - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "low": "[%key:common::state::low%]", "standard": "Standard", - "high": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::high%]" + "high": "[%key:common::state::high%]" } }, "startup_on_off": { @@ -230,7 +230,7 @@ "name": "Contamination state", "state": { "normal": "Normal", - "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "low": "[%key:common::state::low%]", "warning": "Warning", "critical": "Critical" } From 48cbe226093e50ffc157527494ee3ee65f49f8e9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:36:04 +0200 Subject: [PATCH 081/217] =?UTF-8?q?Replace=20"Sign=20into=20=E2=80=A6"=20w?= =?UTF-8?q?ith=20"Sign=20in=20to=20=E2=80=A6"=20in=20`sharkiq`=20(#142087)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix grammar bug "to sign into" in `sharkiq` --- homeassistant/components/sharkiq/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 3c4c98db38f..33826baaf5b 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -3,7 +3,7 @@ "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your SharkClean account to control your devices.", + "description": "Sign in to your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", From 314f658d922bcc144198ab214ac520e4dba4691b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:36:31 +0200 Subject: [PATCH 082/217] Fix grammar bug "to sign into" in `hive` (#142086) --- homeassistant/components/hive/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 6323a2eecbf..5fa15b68d1a 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -34,9 +34,9 @@ } }, "error": { - "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", - "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", - "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", + "invalid_username": "Failed to sign in to Hive. Your email address is not recognised.", + "invalid_password": "Failed to sign in to Hive. Incorrect password, please try again.", + "invalid_code": "Failed to sign in to Hive. Your two-factor authentication code was incorrect.", "no_internet_available": "An Internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" }, From 23ade8180a73dc8f4766b031caa975ed07d7fde0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:09 +0200 Subject: [PATCH 083/217] Replace "to log into" with "to log in to" in `honeywell` (#142063) --- homeassistant/components/honeywell/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index ca152b99ccf..67295ec5802 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "description": "Please enter the credentials used to log in to mytotalconnectcomfort.com.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" From 06edb2e36ba21982a43baab94ade74a7f8260007 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:19 +0200 Subject: [PATCH 084/217] Use common states for selectors in `openai_conversation` (#142056) --- .../components/openai_conversation/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 91c1c475bd6..42baf40d470 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -46,16 +46,16 @@ "selector": { "reasoning_effort": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "search_context_size": { "options": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, From 5d0de138f67410b0de6167c4d737f655f757d25e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:27 +0200 Subject: [PATCH 085/217] Use common states for "speed" in `motionblinds_ble` (#142050) --- homeassistant/components/motionblinds_ble/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index cc7cbbd69e2..4589c2d873b 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -62,9 +62,9 @@ "speed": { "name": "Speed", "state": { - "1": "Low", - "2": "Medium", - "3": "High" + "1": "[%key:common::state::low%]", + "2": "[%key:common::state::medium%]", + "3": "[%key:common::state::high%]" } } }, From d13beec3e1bc1c2bd83e6e6a0d95d603641898b1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:42 +0200 Subject: [PATCH 086/217] Use more common states for "foot_warmer_temp" in `sleepiq` (#142048) --- homeassistant/components/sleepiq/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 60f6026304b..634202d6da8 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -29,9 +29,9 @@ "foot_warmer_temp": { "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } From d56a3ac652e0d6c0dcaf97ed5ac5c5da464e5982 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:37:56 +0200 Subject: [PATCH 087/217] Use common states for "wi_fi_strength" in `aquacell` (#142047) --- homeassistant/components/aquacell/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json index 53304d04804..e07adf3c199 100644 --- a/homeassistant/components/aquacell/strings.json +++ b/homeassistant/components/aquacell/strings.json @@ -36,9 +36,9 @@ "wi_fi_strength": { "name": "Wi-Fi strength", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } } From 519a416837796349062ba786d5df8d6bfca23719 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Apr 2025 22:38:14 +0200 Subject: [PATCH 088/217] Use common states for "ptc_level" in `xiaomi_miio` (#142044) --- homeassistant/components/xiaomi_miio/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bd3b3499689..7df4dc18283 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -88,9 +88,9 @@ }, "ptc_level": { "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, From ec96e54f879c38c150816a578e1d893a19ee677d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Apr 2025 22:39:35 +0200 Subject: [PATCH 089/217] Avoid unnecessary reload in apple_tv reauth flow (#142079) --- homeassistant/components/apple_tv/config_flow.py | 5 ++++- tests/components/apple_tv/test_config_flow.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 76c4681a30d..b026da33231 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -20,6 +20,7 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_IGNORE, + SOURCE_REAUTH, SOURCE_ZEROCONF, ConfigEntry, ConfigFlow, @@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): CONF_IDENTIFIERS: list(combined_identifiers), }, ) - if entry.source != SOURCE_IGNORE: + # Don't reload ignored entries or in the middle of reauth, + # e.g. if the user is entering a new PIN + if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH: self.hass.config_entries.async_schedule_reload(entry.entry_id) if not allow_exist: raise DeviceAlreadyConfigured diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index a13eb3c605b..c9d698e068b 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -59,12 +59,12 @@ def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: @pytest.fixture(autouse=True) -def mock_setup_entry() -> Generator[None]: +def mock_setup_entry() -> Generator[Mock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.apple_tv.async_setup_entry", return_value=True - ): - yield + ) as setup_entry: + yield setup_entry # User Flows @@ -1183,7 +1183,9 @@ async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> N @pytest.mark.usefixtures("mrp_device", "pairing") -async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: +async def test_reconfigure_update_credentials( + hass: HomeAssistant, mock_setup_entry: Mock +) -> None: """Test that reconfigure flow updates config entry.""" config_entry = MockConfigEntry( domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]} @@ -1215,6 +1217,9 @@ async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: "identifiers": ["mrpid"], } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + # Options From e8335b1ed77b7e0b918e3b3be17a61b84ba5ed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 2 Apr 2025 21:42:38 +0100 Subject: [PATCH 090/217] Revert "Move setup messages from info to debug level" (#142023) Revert "Move setup messages from info to debug level (#141834)" This reverts commit 663d0691a780c25c9fa93ec4fae16bcc08966609. --- homeassistant/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 7f037482f0d..aeaea1146a1 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -386,7 +386,7 @@ async def _async_setup_component( }, ) - _LOGGER.debug("Setting up %s", domain) + _LOGGER.info("Setting up %s", domain) with async_start_setup(hass, integration=domain, phase=SetupPhases.SETUP): if hasattr(component, "PLATFORM_SCHEMA"): @@ -782,7 +782,7 @@ def async_start_setup( # platforms, but we only care about the longest time. group_setup_times[phase] = max(group_setup_times[phase], time_taken) if group is None: - _LOGGER.debug( + _LOGGER.info( "Setup of domain %s took %.2f seconds", integration, time_taken ) elif _LOGGER.isEnabledFor(logging.DEBUG): From 02ca1f288998ff97c11bb76ca00f45ecf1f616fb Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Wed, 2 Apr 2025 14:08:13 -0700 Subject: [PATCH 091/217] Fix strings username data description in NUT (#142115) Fix strings username data description --- homeassistant/components/nut/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index dc232cd28c6..bda377b9bae 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -12,7 +12,7 @@ "data_description": { "host": "The IP address or hostname of your NUT server.", "port": "The network port of your NUT server. The NUT server's default port is '3493'.", - "username": "The username to sign in to your NUT server. The usernmae is optional.", + "username": "The username to sign in to your NUT server. The username is optional.", "password": "The password to sign in to your NUT server. The password is optional." } }, From 33d895bc7d4b3116af83c42750c060929b2d0509 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 3 Apr 2025 00:14:07 +0200 Subject: [PATCH 092/217] Use snapshot_platform in all platform test modules for AVM Fritz!SmartHome (#142093) use snapshot_platform in all platform test modules --- tests/components/fritzbox/__init__.py | 6 +- .../snapshots/test_binary_sensor.ambr | 145 ++++ .../fritzbox/snapshots/test_button.ambr | 48 ++ .../fritzbox/snapshots/test_climate.ambr | 80 ++ .../fritzbox/snapshots/test_cover.ambr | 51 ++ .../fritzbox/snapshots/test_light.ambr | 278 ++++++ .../fritzbox/snapshots/test_sensor.ambr | 810 ++++++++++++++++++ .../fritzbox/snapshots/test_switch.ambr | 48 ++ .../components/fritzbox/test_binary_sensor.py | 84 +- tests/components/fritzbox/test_button.py | 42 +- tests/components/fritzbox/test_climate.py | 172 +--- tests/components/fritzbox/test_cover.py | 48 +- tests/components/fritzbox/test_light.py | 116 ++- tests/components/fritzbox/test_sensor.py | 99 +-- tests/components/fritzbox/test_switch.py | 113 +-- 15 files changed, 1683 insertions(+), 457 deletions(-) create mode 100644 tests/components/fritzbox/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/fritzbox/snapshots/test_button.ambr create mode 100644 tests/components/fritzbox/snapshots/test_climate.ambr create mode 100644 tests/components/fritzbox/snapshots/test_cover.ambr create mode 100644 tests/components/fritzbox/snapshots/test_light.ambr create mode 100644 tests/components/fritzbox/snapshots/test_sensor.ambr create mode 100644 tests/components/fritzbox/snapshots/test_switch.ambr diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 034b86497db..1f310e1d3cb 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -25,7 +25,7 @@ async def setup_config_entry( device: Mock | None = None, fritz: Mock | None = None, template: Mock | None = None, -) -> bool: +) -> MockConfigEntry: """Do setup of a MockConfigEntry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -39,10 +39,10 @@ async def setup_config_entry( if template is not None and fritz is not None: fritz().get_templates.return_value = [template] - result = await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) if device is not None: await hass.async_block_till_done() - return result + return entry def set_devices( diff --git a/tests/components/fritzbox/snapshots/test_binary_sensor.ambr b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5b3e00dfa93 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_binary_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.fake_name_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '12345 1234567_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'fake_name Alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_button_lock_on_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock on device', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '12345 1234567_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_on_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock on device', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_on_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_button_lock_via_ui', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button lock via UI', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_lock', + 'unique_id': '12345 1234567_device_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.fake_name_button_lock_via_ui-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'fake_name Button lock via UI', + }), + 'context': , + 'entity_id': 'binary_sensor.fake_name_button_lock_via_ui', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_button.ambr b/tests/components/fritzbox/snapshots/test_button.ambr new file mode 100644 index 00000000000..95e757da3cc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[button.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[button.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'button.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_climate.ambr b/tests/components/fritzbox/snapshots/test_climate.ambr new file mode 100644 index 00000000000..26e06105152 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_climate.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_setup[climate.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'thermostat', + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[climate.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 23, + 'battery_low': True, + 'current_temperature': 18.0, + 'friendly_name': 'fake_name', + 'holiday_mode': False, + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 8.0, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'comfort', + 'boost', + ]), + 'summer_mode': False, + 'supported_features': , + 'temperature': 19.5, + 'window_open': 'fake_window', + }), + 'context': , + 'entity_id': 'climate.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_cover.ambr b/tests/components/fritzbox/snapshots/test_cover.ambr new file mode 100644 index 00000000000..ce6b305e154 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_cover.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[cover.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[cover.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'fake_name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_light.ambr b/tests/components/fritzbox/snapshots/test_light.ambr new file mode 100644 index 00000000000..f6f4516bdec --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_light.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_setup[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': 370, + 'color_temp_kelvin': 2700, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 28.395, + 65.723, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 87, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.525, + 0.388, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'fake_name', + 'hs_color': tuple( + 100, + 70.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 370, + 'min_color_temp_kelvin': 2700, + 'min_mireds': 153, + 'rgb_color': tuple( + 136, + 255, + 77, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.271, + 0.609, + ), + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 100, + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_non_color_non_level[light.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'fake_name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_sensor.ambr b/tests/components/fritzbox/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..68f8e161d07 --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_sensor.ambr @@ -0,0 +1,810 @@ +# serializer version: 1 +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceBinarySensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_comfort_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Comfort temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'comfort_temperature', + 'unique_id': '12345 1234567_comfort_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_comfort_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Comfort temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_comfort_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_current_scheduled_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scheduled_preset', + 'unique_id': '12345 1234567_scheduled_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_current_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Current scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_current_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eco', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_eco_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eco temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'eco_temperature', + 'unique_id': '12345 1234567_eco_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_eco_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Eco temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_eco_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_next_scheduled_change_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled change time', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_time', + 'unique_id': '12345 1234567_nextchange_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_change_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'fake_name Next scheduled change time', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_change_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-20T18:00:00+00:00', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_next_scheduled_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next scheduled preset', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_preset', + 'unique_id': '12345 1234567_nextchange_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name Next scheduled preset', + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'comfort', + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_next_scheduled_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nextchange_temperature', + 'unique_id': '12345 1234567_nextchange_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_next_scheduled_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Next scheduled temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_next_scheduled_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.fake_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'fake_name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'fake_name Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fake_name_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSensorMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_electric_current', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'fake_name Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.025', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'fake_name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_power_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'fake_name Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.678', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.fake_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceSwitchMock][sensor.fake_name_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'fake_name Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- diff --git a/tests/components/fritzbox/snapshots/test_switch.ambr b/tests/components/fritzbox/snapshots/test_switch.ambr new file mode 100644 index 00000000000..23deb8183fc --- /dev/null +++ b/tests/components/fritzbox/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_setup[switch.fake_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.fake_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake_name', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[switch.fake_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'fake_name', + }), + 'context': , + 'entity_id': 'switch.fake_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index d5b0b5d196b..3244d007fa6 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -2,87 +2,49 @@ from datetime import timedelta from unittest import mock -from unittest.mock import Mock +from unittest.mock import Mock, patch from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorDeviceClass, -) +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BINARY_SENSOR_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BINARY_SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(f"{ENTITY_ID}_alarm") - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Alarm" - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_on_device") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Button lock on device" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_button_lock_via_ui") - assert state - assert state.state == STATE_OFF - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Button lock via UI" - ) - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: """Test state of platform.""" device = FritzDeviceBinarySensorMock() device.present = False - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -102,7 +64,7 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -121,7 +83,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceBinarySensorMock() device.update.side_effect = [mock.DEFAULT, HTTPError("Boom")] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -139,7 +101,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceBinarySensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 0053a8d3446..5280cd7cc83 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -1,44 +1,50 @@ """Tests for AVM Fritz!Box templates.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch + +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - CONF_DEVICES, - STATE_UNKNOWN, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{BUTTON_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test if is initialized correctly.""" template = FritzEntityBaseMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.BUTTON]): + entry = await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + fritz=fritz, + template=template, + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.state == STATE_UNKNOWN + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: """Test if applies works.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) @@ -51,7 +57,7 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" template = FritzEntityBaseMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], fritz=fritz, template=template ) diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 699a2b8c53e..e21191fcbbb 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -1,11 +1,12 @@ """Tests for AVM Fritz!Box climate component.""" from datetime import timedelta -from unittest.mock import Mock, _Call, call +from unittest.mock import Mock, _Call, call, patch from freezegun.api import FrozenDateTimeFactory import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -31,29 +32,15 @@ from homeassistant.components.fritzbox.climate import ( PRESET_SUMMER, ) from homeassistant.components.fritzbox.const import ( - ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, - ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_DEVICES, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -64,127 +51,31 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{CLIMATE_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.CLIMATE]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) - state = hass.states.get(ENTITY_ID) - assert state - assert state.attributes[ATTR_BATTERY_LEVEL] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] - assert state.attributes[ATTR_MAX_TEMP] == 28 - assert state.attributes[ATTR_MIN_TEMP] == 8 - assert state.attributes[ATTR_PRESET_MODE] is None - assert state.attributes[ATTR_PRESET_MODES] == [ - PRESET_ECO, - PRESET_COMFORT, - PRESET_BOOST, - ] - assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False - assert state.attributes[ATTR_STATE_SUMMER_MODE] is False - assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" - assert state.attributes[ATTR_TEMPERATURE] == 19.5 - assert ATTR_STATE_CLASS not in state.attributes - assert state.state == HVACMode.HEAT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_comfort_temperature") - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Comfort temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_eco_temperature") - assert state - assert state.state == "16.0" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Eco temperature" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_temperature" - ) - assert state - assert state.state == "22.0" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled temperature" - ) - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" - ) - assert state - assert state.state == "2024-09-20T18:00:00+00:00" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled change time" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_COMFORT - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Next scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_ECO - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{CONF_FAKE_NAME} Current scheduled preset" - ) - assert ATTR_STATE_CLASS not in state.attributes - - device.nextchange_temperature = 16 - - next_update = dt_util.utcnow() + timedelta(seconds=200) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") - assert state - assert state.state == PRESET_ECO - - state = hass.states.get( - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current_scheduled_preset" - ) - assert state - assert state.state == PRESET_COMFORT + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_hkr_wo_temperature_sensor(hass: HomeAssistant, fritz: Mock) -> None: """Test hkr without exposing dedicated temperature sensor data block.""" device = FritzDeviceClimateWithoutTempSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -197,7 +88,7 @@ async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 127.0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -210,7 +101,7 @@ async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceClimateMock() device.target_temperature = 126.5 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -222,7 +113,7 @@ async def test_target_temperature_off(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -253,7 +144,7 @@ async def test_automatic_offset(hass: HomeAssistant, fritz: Mock) -> None: device.temperature = 18 device.actual_temperature = 19 device.target_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -269,9 +160,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceClimateMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -312,7 +204,7 @@ async def test_set_temperature( ) -> None: """Test setting temperature.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -366,7 +258,7 @@ async def test_set_hvac_mode( else: device.nextchange_endperiod = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -398,7 +290,7 @@ async def test_set_preset_mode_comfort( """Test setting preset mode.""" device = FritzDeviceClimateMock() device.comfort_temperature = comfort_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -429,7 +321,7 @@ async def test_set_preset_mode_eco( """Test setting preset mode.""" device = FritzDeviceClimateMock() device.eco_temperature = eco_temperature - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -449,7 +341,7 @@ async def test_set_preset_mode_boost( ) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -468,7 +360,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceClimateMock() device.comfort_temperature = 23 device.eco_temperature = 20 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -513,7 +405,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -538,7 +430,7 @@ async def test_holidy_summer_mode( ) -> None: """Test holiday and summer mode.""" device = FritzDeviceClimateMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 535306e4ef2..a1332e9715b 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -1,15 +1,13 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_POSITION, - DOMAIN as COVER_DOMAIN, - CoverState, -) +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICES, @@ -18,8 +16,10 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( @@ -30,28 +30,32 @@ from . import ( ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{COVER_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.COVER]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == CoverState.OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 100 + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: """Test cover with unknown position.""" device = FritzDeviceCoverUnknownPositionMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -63,7 +67,7 @@ async def test_unknown_position(hass: HomeAssistant, fritz: Mock) -> None: async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test opening the cover.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -76,7 +80,7 @@ async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test closing the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -89,7 +93,7 @@ async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -105,7 +109,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: """Test stopping the device.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -118,7 +122,7 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceCoverMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index fe8bb32066e..d9a81bf8f21 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -1,9 +1,10 @@ """Tests for AVM Fritz!Box light component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import ( COLOR_MODE, @@ -12,35 +13,36 @@ from homeassistant.components.fritzbox.const import ( ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, - ColorMode, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{LIGHT_DOMAIN}.{CONF_FAKE_NAME}" -async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -50,42 +52,42 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: device.color_mode = COLOR_TEMP_MODE device.color_temp = 2700 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 - assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 - assert state.attributes[ATTR_HS_COLOR] == (28.395, 65.723) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color bulb.""" device = FritzDeviceLightMock() device.has_color = False device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_non_color_non_level( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform of non color and non level bulb.""" device = FritzDeviceLightMock() device.has_color = False @@ -93,22 +95,21 @@ async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> No device.get_color_temps.return_value = [] device.get_colors.return_value = {} - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert ATTR_BRIGHTNESS not in state.attributes - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.ONOFF - assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) is None - assert state.attributes.get(ATTR_HS_COLOR) is None + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: +async def test_setup_color( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, +) -> None: """Test setup of platform in color mode.""" device = FritzDeviceLightMock() device.get_color_temps.return_value = [2700, 6500] @@ -119,19 +120,13 @@ async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: device.hue = 100 device.saturation = 70 * 255.0 / 100.0 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.LIGHT]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] is None - assert state.attributes[ATTR_BRIGHTNESS] == 100 - assert state.attributes[ATTR_HS_COLOR] == (100, 70) - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: @@ -258,9 +253,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index cb136eee993..28d21f9fd39 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -1,97 +1,69 @@ """Tests for AVM Fritz!Box sensor component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - CONF_DEVICES, - PERCENTAGE, - STATE_UNKNOWN, - EntityCategory, - UnitOfTemperature, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICES, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import ( + FritzDeviceBinarySensorMock, FritzDeviceClimateMock, FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, set_devices, setup_config_entry, ) from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}" +@pytest.mark.parametrize( + "device", + [ + FritzDeviceBinarySensorMock, + FritzDeviceClimateMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + ], +) async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, + device: FritzEntityBaseMock, ) -> None: - """Test setup of platform.""" - device = FritzDeviceSensorMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - await hass.async_block_till_done() + """Test setup of sensor platform for different device types.""" + device = device() - sensors = ( - [ - f"{ENTITY_ID}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_humidity", - "42", - f"{CONF_FAKE_NAME} Humidity", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{ENTITY_ID}_battery", - "23", - f"{CONF_FAKE_NAME} Battery", - PERCENTAGE, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SENSOR]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes.get(ATTR_STATE_CLASS) == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -109,9 +81,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSensorMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -126,7 +99,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSensorMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -175,7 +148,7 @@ async def test_next_change_sensors( device.nextchange_endperiod = next_changes[0] device.nextchange_temperature = next_changes[1] - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 511725c663f..cb6b563d344 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -1,33 +1,22 @@ """Tests for AVM Fritz!Box switch component.""" from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from requests.exceptions import HTTPError +from syrupy import SnapshotAssertion from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_ON, STATE_UNAVAILABLE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,89 +26,32 @@ from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup( - hass: HomeAssistant, entity_registry: er.EntityRegistry, fritz: Mock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fritz: Mock, ) -> None: """Test setup of platform.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) + with patch("homeassistant.components.fritzbox.PLATFORMS", [Platform.SWITCH]): + entry = await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert entry.state is ConfigEntryState.LOADED - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert ATTR_STATE_CLASS not in state.attributes - - state = hass.states.get(f"{ENTITY_ID}_humidity") - assert state is None - - sensors = ( - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature", - "1.23", - f"{CONF_FAKE_NAME} Temperature", - UnitOfTemperature.CELSIUS, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power", - "5.678", - f"{CONF_FAKE_NAME} Power", - UnitOfPower.WATT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_energy", - "1.234", - f"{CONF_FAKE_NAME} Energy", - UnitOfEnergy.KILO_WATT_HOUR, - SensorStateClass.TOTAL_INCREASING, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_voltage", - "230.0", - f"{CONF_FAKE_NAME} Voltage", - UnitOfElectricPotential.VOLT, - SensorStateClass.MEASUREMENT, - None, - ], - [ - f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current", - "0.025", - f"{CONF_FAKE_NAME} Current", - UnitOfElectricCurrent.AMPERE, - SensorStateClass.MEASUREMENT, - None, - ], - ) - - for sensor in sensors: - state = hass.states.get(sensor[0]) - assert state - assert state.state == sensor[1] - assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - assert state.attributes[ATTR_STATE_CLASS] == sensor[4] - entry = entity_registry.async_get(sensor[0]) - assert entry - assert entry.entity_category is sensor[5] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device on.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -133,7 +65,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device off.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -149,7 +81,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: device = FritzDeviceSwitchMock() device.lock = True - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -173,7 +105,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: async def test_update(hass: HomeAssistant, fritz: Mock) -> None: """Test update without error.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) assert fritz().update_devices.call_count == 1 @@ -191,9 +123,10 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: """Test update with error.""" device = FritzDeviceSwitchMock() fritz().update_devices.side_effect = HTTPError("Boom") - assert not await setup_config_entry( + entry = await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + assert entry.state is ConfigEntryState.SETUP_RETRY assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 2 @@ -211,7 +144,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No device.voltage = 0 device.energy = 0 device.power = 0 - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -223,7 +156,7 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: """Test adding new discovered devices during runtime.""" device = FritzDeviceSwitchMock() - assert await setup_config_entry( + await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) From 09d25f322a99ee07ea62502223ab228eec0294b2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 3 Apr 2025 16:12:41 +1000 Subject: [PATCH 093/217] Bump tesla-fleet-api to v1.0.17 (#142131) bump --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 56dc49ad111..53c8e7d554c 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.16"] + "requirements": ["tesla-fleet-api==1.0.17"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index cae5a8f3c01..a8f1fd0daec 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.16", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.6.12"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 3f96bb226ab..3f71bcb95e3 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.16"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccd12e70731..ed270eb4747 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2878,7 +2878,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.16 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25fa47b9413..f580528a6f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2316,7 +2316,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.16 +tesla-fleet-api==1.0.17 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 1860db4632b3f0a5f047790ae4453edce7be7ea7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 08:13:16 +0200 Subject: [PATCH 094/217] Use common state for "Medium" in `iron_os` (#142117) --- homeassistant/components/iron_os/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index ddae9a3020f..629f7c32c9b 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -123,7 +123,7 @@ "state": { "off": "[%key:common::state::off%]", "slow": "[%key:component::iron_os::common::slow%]", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "fast": "[%key:component::iron_os::common::fast%]" } }, From df5cdf7de4a37659e8035d36c911186f7b6f3437 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 08:14:02 +0200 Subject: [PATCH 095/217] Use common states for "Low"/"Medium"/"High" in `litterrobot` (#142112) --- homeassistant/components/litterrobot/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 052427f3032..55dbc0ea645 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -118,9 +118,9 @@ "brightness_level": { "name": "Panel brightness", "state": { - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } } }, From 03c70e18dffba71b2d054a56c74ab648150dfe47 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 08:14:14 +0200 Subject: [PATCH 096/217] Use common states for "Low"/"Medium"/"High" in `roborock` (#142113) --- homeassistant/components/roborock/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 4546856ec8b..d27f4064170 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -368,12 +368,12 @@ "name": "Mop intensity", "state": { "off": "[%key:common::state::off%]", - "low": "Low", + "low": "[%key:common::state::low%]", "mild": "Mild", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "moderate": "Moderate", "max": "Max", - "high": "High", + "high": "[%key:common::state::high%]", "intense": "Intense", "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "custom_water_flow": "Custom water flow", @@ -433,7 +433,7 @@ "off": "[%key:common::state::off%]", "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]", "max_plus": "Max plus", - "medium": "Medium", + "medium": "[%key:common::state::medium%]", "quiet": "Quiet", "silent": "Silent", "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", From 4a562b5085cab94caae25a3e1b1a6b3432cd09b7 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:45:06 +0200 Subject: [PATCH 097/217] Improve exception handling in Pterodactyl (#141955) Improve exception handling --- homeassistant/components/pterodactyl/api.py | 47 +++++++------- .../components/pterodactyl/button.py | 12 +++- .../components/pterodactyl/config_flow.py | 6 +- .../components/pterodactyl/coordinator.py | 6 +- .../components/pterodactyl/strings.json | 1 + .../pterodactyl/test_config_flow.py | 64 ++++++------------- 6 files changed, 61 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index a60962ecf51..40ede9de103 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -5,20 +5,16 @@ from enum import StrEnum import logging from pydactyl import PterodactylClient -from pydactyl.exceptions import ( - BadRequestError, - ClientConfigError, - PterodactylApiError, - PydactylError, -) +from pydactyl.exceptions import BadRequestError, PterodactylApiError +from requests.exceptions import ConnectionError, HTTPError from homeassistant.core import HomeAssistant _LOGGER = logging.getLogger(__name__) -class PterodactylConfigurationError(Exception): - """Raised when the configuration is invalid.""" +class PterodactylAuthorizationError(Exception): + """Raised when access to server is unauthorized.""" class PterodactylConnectionError(Exception): @@ -75,13 +71,12 @@ class PterodactylAPI: paginated_response = await self.hass.async_add_executor_job( self.pterodactyl.client.servers.list_servers ) - except ClientConfigError as error: - raise PterodactylConfigurationError(error) from error - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error else: game_servers = paginated_response.collect() @@ -108,11 +103,12 @@ class PterodactylAPI: server, utilization = await self.hass.async_add_executor_job( self.get_server_data, identifier ) - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error else: data[identifier] = PterodactylData( @@ -145,9 +141,10 @@ class PterodactylAPI: identifier, command, ) - except ( - PydactylError, - BadRequestError, - PterodactylApiError, - ) as error: + except (BadRequestError, PterodactylApiError, ConnectionError) as error: + raise PterodactylConnectionError(error) from error + except HTTPError as error: + if error.response.status_code == 401: + raise PterodactylAuthorizationError(error) from error + raise PterodactylConnectionError(error) from error diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py index a1201f3ced5..44d3a6d0a82 100644 --- a/homeassistant/components/pterodactyl/button.py +++ b/homeassistant/components/pterodactyl/button.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .api import PterodactylCommand, PterodactylConnectionError +from .api import ( + PterodactylAuthorizationError, + PterodactylCommand, + PterodactylConnectionError, +) from .coordinator import PterodactylConfigEntry, PterodactylCoordinator from .entity import PterodactylEntity @@ -94,5 +98,9 @@ class PterodactylButtonEntity(PterodactylEntity, ButtonEntity): ) except PterodactylConnectionError as err: raise HomeAssistantError( - f"Failed to send action '{self.entity_description.key}'" + f"Failed to send action '{self.entity_description.key}': Connection error" + ) from err + except PterodactylAuthorizationError as err: + raise HomeAssistantError( + f"Failed to send action '{self.entity_description.key}': Unauthorized" ) from err diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index a36069d2bb9..e78ae776123 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_API_KEY, CONF_URL from .api import ( PterodactylAPI, - PterodactylConfigurationError, + PterodactylAuthorizationError, PterodactylConnectionError, ) from .const import DOMAIN @@ -49,7 +49,9 @@ class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): try: await api.async_init() - except (PterodactylConfigurationError, PterodactylConnectionError): + except PterodactylAuthorizationError: + errors["base"] = "invalid_auth" + except PterodactylConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception occurred during config flow") diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index 36456ade630..c8456ce9e55 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .api import ( PterodactylAPI, - PterodactylConfigurationError, + PterodactylAuthorizationError, PterodactylConnectionError, PterodactylData, ) @@ -55,12 +55,12 @@ class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): try: await self.api.async_init() - except PterodactylConfigurationError as error: + except (PterodactylAuthorizationError, PterodactylConnectionError) as error: raise UpdateFailed(error) from error async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" try: return await self.api.async_get_data() - except PterodactylConnectionError as error: + except (PterodactylAuthorizationError, PterodactylConnectionError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index 97b33566f39..fe2b7730e1b 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -14,6 +14,7 @@ }, "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%]" }, "abort": { diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 14bb2d2f69f..3cb7f1c19d4 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Pterodactyl config flow.""" from pydactyl import PterodactylClient -from pydactyl.exceptions import ClientConfigError, PterodactylApiError +from pydactyl.exceptions import BadRequestError, PterodactylApiError import pytest +from requests.exceptions import HTTPError +from requests.models import Response from homeassistant.components.pterodactyl.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -14,6 +16,14 @@ from .conftest import TEST_URL, TEST_USER_INPUT from tests.common import MockConfigEntry +def mock_response(): + """Mock HTTP response.""" + mock = Response() + mock.status_code = 401 + + return mock + + @pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") async def test_full_flow(hass: HomeAssistant) -> None: """Test full flow without errors.""" @@ -36,18 +46,21 @@ async def test_full_flow(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( - "exception_type", + ("exception_type", "expected_error"), [ - ClientConfigError, - PterodactylApiError, + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), ], ) -async def test_recovery_after_api_error( +async def test_recovery_after_error( hass: HomeAssistant, - exception_type, + exception_type: Exception, + expected_error: str, mock_pterodactyl: PterodactylClient, ) -> None: - """Test recovery after an API error.""" + """Test recovery after an error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -63,42 +76,7 @@ async def test_recovery_after_api_error( await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - mock_pterodactyl.reset_mock(side_effect=True) - - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_URL - assert result["data"] == TEST_USER_INPUT - - -@pytest.mark.usefixtures("mock_setup_entry") -async def test_recovery_after_unknown_error( - hass: HomeAssistant, - mock_pterodactyl: PterodactylClient, -) -> None: - """Test recovery after an API error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - mock_pterodactyl.client.servers.list_servers.side_effect = Exception - - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], - user_input=TEST_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} + assert result["errors"] == {"base": expected_error} mock_pterodactyl.reset_mock(side_effect=True) From db44ed845da912ff1f03a1740d9ce9ceea064067 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:24:48 +0200 Subject: [PATCH 098/217] Use common states for "Low"/"Medium"/"High" in `ecovacs` (#142140) --- homeassistant/components/ecovacs/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 515eb1c3141..f74c8b90f00 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -176,9 +176,9 @@ "water_amount": { "name": "Water flow level", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "ultrahigh": "Ultrahigh" } }, From dfa180ba64e0c21ac9fc981b35440323926794f4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:33:06 +0200 Subject: [PATCH 099/217] Use common states for "Low"/"Medium"/"High" in `home_connect` (#142142) * Use common states for "Low"/"Medium"/"High" in `home_connect` Replaces two occurrences of "Low"/"Medium"/"High" each with the (new) common strings. * Replace internal references with common ones --- .../components/home_connect/strings.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index ad7f67968f5..dfbe1ca26fe 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -487,9 +487,9 @@ }, "warming_level": { "options": { - "cooking_oven_enum_type_warming_level_low": "Low", - "cooking_oven_enum_type_warming_level_medium": "Medium", - "cooking_oven_enum_type_warming_level_high": "High" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -522,9 +522,9 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", - "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", - "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { @@ -1468,9 +1468,9 @@ "warming_level": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", "state": { - "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", - "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", - "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]" } }, "washer_temperature": { @@ -1505,9 +1505,9 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]", - "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", - "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", - "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]" } }, "vario_perfect": { From 0b61b6233436af6c027ce0ca9549a2d2a7851e45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Apr 2025 21:38:50 -1000 Subject: [PATCH 100/217] Avoid logging a warning when replacing an ignored config entry (#142114) Replacing an ignored config entry with one from the user flow should not generate a warning. We should only warn if we are replacing a usable config entry. Followup to adjust the warning added in #130567 cc @epenet --- homeassistant/config_entries.py | 6 +++++- tests/test_config_entries.py | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 81df30210e1..73393ba75d5 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1612,7 +1612,11 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) - if existing_entry is not None and flow.handler != "mobile_app": + if ( + existing_entry is not None + and flow.handler != "mobile_app" + and existing_entry.source != SOURCE_IGNORE + ): # This causes the old entry to be removed and replaced, when the flow # should instead be aborted. # In case of manual flows, integrations should implement options, reauth, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 6147102f68f..2d9d18a067d 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8797,15 +8797,17 @@ async def test_add_description_placeholder_automatically_not_overwrites( @pytest.mark.parametrize( - ("domain", "expected_log"), + ("domain", "source", "expected_log"), [ - ("some_integration", True), - ("mobile_app", False), + ("some_integration", config_entries.SOURCE_USER, True), + ("some_integration", config_entries.SOURCE_IGNORE, False), + ("mobile_app", config_entries.SOURCE_USER, False), ], ) async def test_create_entry_existing_unique_id( hass: HomeAssistant, domain: str, + source: str, expected_log: bool, caplog: pytest.LogCaptureFixture, ) -> None: @@ -8816,6 +8818,7 @@ async def test_create_entry_existing_unique_id( entry_id="01J915Q6T9F6G5V0QJX6HBC94T", data={"host": "any", "port": 123}, unique_id="mock-unique-id", + source=source, ) entry.add_to_hass(hass) From ec396513a236e014c8af40524492654265cb09ef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 3 Apr 2025 09:43:00 +0200 Subject: [PATCH 101/217] Bump pysmhi to 1.0.1 (#142111) --- homeassistant/components/smhi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index fc3af634764..89443fc7e27 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", "loggers": ["pysmhi"], - "requirements": ["pysmhi==1.0.0"] + "requirements": ["pysmhi==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ed270eb4747..7ab60f23782 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ pysmartthings==3.0.1 pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.1 # homeassistant.components.edl21 pysml==0.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f580528a6f9..a60f8d913e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,7 +1895,7 @@ pysmartthings==3.0.1 pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.0 +pysmhi==1.0.1 # homeassistant.components.edl21 pysml==0.0.12 From 98c56bce4b07fd0c55f37409f5f121e0f50bbfcc Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Apr 2025 09:46:09 +0200 Subject: [PATCH 102/217] Bump pyenphase to 1.25.5 (#142107) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index e51a7427504..88183fe4cfd 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.25.1"], + "requirements": ["pyenphase==1.25.5"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 7ab60f23782..c43c9db93c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1948,7 +1948,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.25.5 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a60f8d913e3..5f6810fd248 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1590,7 +1590,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.25.1 +pyenphase==1.25.5 # homeassistant.components.everlights pyeverlights==0.1.0 From 1d694450ef2ecf4922b7bbc3edb473f783a3c451 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:46:49 +0200 Subject: [PATCH 103/217] Use common states for "Low" and "High" in `balboa` (#142150) --- homeassistant/components/balboa/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 784ce8533a8..8297e2e3b9f 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -103,8 +103,8 @@ "temperature_range": { "name": "Temperature range", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } } }, From c2eb72fce486bc4fb6ace0e3c3bd04b0e96fc2bc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 09:47:54 +0200 Subject: [PATCH 104/217] Use common states for "Low" and "High" in `yale_smart_alarm` (#142149) --- homeassistant/components/yale_smart_alarm/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index ebcf0b3af63..fd8d403da8d 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -71,8 +71,8 @@ "volume": { "name": "Volume", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } } From b7bc9607a2972301093f6b110c17db1ab0876c24 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Apr 2025 10:21:26 +0200 Subject: [PATCH 105/217] Fix lying comment in ConfigEntriesFlowManager.async_finish_flow (#142146) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 73393ba75d5..016b199744c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1526,7 +1526,7 @@ class ConfigEntriesFlowManager( ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: - # If there's an ignored config entry with a matching unique ID, + # If there's a config entry with a matching unique ID, # update the discovery key. if ( (discovery_key := flow.context.get("discovery_key")) From 8b3a43258dd673ae4db714e983e253d92eff0667 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 10:31:45 +0200 Subject: [PATCH 106/217] Use common states for "Low" and "High" in `dsmr_reader` (#142159) --- homeassistant/components/dsmr_reader/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 90cf0533a72..d405898a393 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -140,8 +140,8 @@ "electricity_tariff": { "name": "Electricity tariff", "state": { - "low": "Low", - "high": "High" + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" } }, "power_failure_count": { From 934e81db43f5b9b06ba4cff2dab83170e8652671 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:48:16 +0800 Subject: [PATCH 107/217] Bump PySwitchBot to 0.59.0 (#142166) update pyswitchbot to 0590 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index d9f6f98d1fd..3c68facf1e9 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.58.0"] + "requirements": ["PySwitchbot==0.59.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c43c9db93c6..51c5e7296a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.58.0 +PySwitchbot==0.59.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f6810fd248..83ff3ab8315 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.58.0 +PySwitchbot==0.59.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From 7a9a4db8d7d5859edaf6a884f4133cb6cfaae11d Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Thu, 3 Apr 2025 23:05:08 +1300 Subject: [PATCH 108/217] Add diagnostics for bosch alam integration (#142165) * add diagnostics to bosch_alarm * use snapshot --- .../components/bosch_alarm/diagnostics.py | 73 +++++ tests/components/bosch_alarm/conftest.py | 68 +++- .../snapshots/test_diagnostics.ambr | 290 ++++++++++++++++++ .../bosch_alarm/test_diagnostics.py | 32 ++ 4 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/bosch_alarm/diagnostics.py create mode 100644 tests/components/bosch_alarm/snapshots/test_diagnostics.ambr create mode 100644 tests/components/bosch_alarm/test_diagnostics.py diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py new file mode 100644 index 00000000000..2e93052ea95 --- /dev/null +++ b/homeassistant/components/bosch_alarm/diagnostics.py @@ -0,0 +1,73 @@ +"""Diagnostics for bosch alarm.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from . import BoschAlarmConfigEntry +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE + +TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BoschAlarmConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": { + "model": entry.runtime_data.model, + "serial_number": entry.runtime_data.serial_number, + "protocol_version": entry.runtime_data.protocol_version, + "firmware_version": entry.runtime_data.firmware_version, + "areas": [ + { + "id": area_id, + "name": area.name, + "all_ready": area.all_ready, + "part_ready": area.part_ready, + "faults": area.faults, + "alarms": area.alarms, + "disarmed": area.is_disarmed(), + "arming": area.is_arming(), + "pending": area.is_pending(), + "part_armed": area.is_part_armed(), + "all_armed": area.is_all_armed(), + "armed": area.is_armed(), + "triggered": area.is_triggered(), + } + for area_id, area in entry.runtime_data.areas.items() + ], + "points": [ + { + "id": point_id, + "name": point.name, + "open": point.is_open(), + "normal": point.is_normal(), + } + for point_id, point in entry.runtime_data.points.items() + ], + "doors": [ + { + "id": door_id, + "name": door.name, + "open": door.is_open(), + "locked": door.is_locked(), + } + for door_id, door in entry.runtime_data.doors.items() + ], + "outputs": [ + { + "id": output_id, + "name": output.name, + "active": output.is_active(), + } + for output_id, output in entry.runtime_data.outputs.items() + ], + "history_events": entry.runtime_data.events, + }, + } diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py index 45ec0072a37..8358624b003 100644 --- a/tests/components/bosch_alarm/conftest.py +++ b/tests/components/bosch_alarm/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch -from bosch_alarm_mode2.panel import Area +from bosch_alarm_mode2.panel import Area, Door, Output, Point from bosch_alarm_mode2.utils import Observable import pytest @@ -78,14 +78,65 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def points() -> Generator[dict[int, Point]]: + """Define a mocked door.""" + names = [ + "Window", + "Door", + "Motion Detector", + "CO Detector", + "Smoke Detector", + "Glassbreak Sensor", + "Bedroom", + ] + points = {} + for i, name in enumerate(names): + mock = AsyncMock(spec=Point) + mock.name = name + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_normal.return_value = True + points[i] = mock + return points + + +@pytest.fixture +def output() -> Generator[Output]: + """Define a mocked output.""" + mock = AsyncMock(spec=Output) + mock.name = "Output A" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_active.return_value = False + return mock + + +@pytest.fixture +def door() -> Generator[Door]: + """Define a mocked door.""" + mock = AsyncMock(spec=Door) + mock.name = "Main Door" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_open.return_value = False + mock.is_locked.return_value = True + return mock + + @pytest.fixture def area() -> Generator[Area]: """Define a mocked area.""" mock = AsyncMock(spec=Area) mock.name = "Area1" mock.status_observer = AsyncMock(spec=Observable) + mock.alarm_observer = AsyncMock(spec=Observable) + mock.ready_observer = AsyncMock(spec=Observable) + mock.alarms = [] + mock.faults = [] + mock.all_ready = True + mock.part_ready = True mock.is_triggered.return_value = False mock.is_disarmed.return_value = True + mock.is_armed.return_value = False mock.is_arming.return_value = False mock.is_pending.return_value = False mock.is_part_armed.return_value = False @@ -95,7 +146,12 @@ def area() -> Generator[Area]: @pytest.fixture def mock_panel( - area: AsyncMock, model_name: str, serial_number: str | None + area: AsyncMock, + door: AsyncMock, + output: AsyncMock, + points: dict[int, AsyncMock], + model_name: str, + serial_number: str | None, ) -> Generator[AsyncMock]: """Define a fixture to set up Bosch Alarm.""" with ( @@ -106,10 +162,18 @@ def mock_panel( ): client = mock_panel.return_value client.areas = {1: area} + client.doors = {1: door} + client.outputs = {1: output} + client.points = points client.model = model_name + client.faults = [] + client.events = [] client.firmware_version = "1.0.0" + client.protocol_version = "1.0.0" client.serial_number = serial_number client.connection_status_observer = AsyncMock(spec=Observable) + client.faults_observer = AsyncMock(spec=Observable) + client.history_observer = AsyncMock(spec=Observable) yield client diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..23ea722325f --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -0,0 +1,290 @@ +# serializer version: 1 +# name: test_diagnostics[amax_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'AMAX 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'installer_code': '**REDACTED**', + 'model': 'AMAX 3000', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[b5512] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'B5512 (US1B)', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': None, + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'B5512 (US1B)', + 'password': '**REDACTED**', + 'port': 7700, + }), + }) +# --- +# name: test_diagnostics[solution_3000] + dict({ + 'data': dict({ + 'areas': list([ + dict({ + 'alarms': list([ + ]), + 'all_armed': False, + 'all_ready': True, + 'armed': False, + 'arming': False, + 'disarmed': True, + 'faults': list([ + ]), + 'id': 1, + 'name': 'Area1', + 'part_armed': False, + 'part_ready': True, + 'pending': False, + 'triggered': False, + }), + ]), + 'doors': list([ + dict({ + 'id': 1, + 'locked': True, + 'name': 'Main Door', + 'open': False, + }), + ]), + 'firmware_version': '1.0.0', + 'history_events': list([ + ]), + 'model': 'Solution 3000', + 'outputs': list([ + dict({ + 'active': False, + 'id': 1, + 'name': 'Output A', + }), + ]), + 'points': list([ + dict({ + 'id': 0, + 'name': 'Window', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 1, + 'name': 'Door', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 2, + 'name': 'Motion Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 3, + 'name': 'CO Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 4, + 'name': 'Smoke Detector', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 5, + 'name': 'Glassbreak Sensor', + 'normal': True, + 'open': False, + }), + dict({ + 'id': 6, + 'name': 'Bedroom', + 'normal': True, + 'open': False, + }), + ]), + 'protocol_version': '1.0.0', + 'serial_number': '1234567890', + }), + 'entry_data': dict({ + 'host': '0.0.0.0', + 'model': 'Solution 3000', + 'port': 7700, + 'user_code': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bosch_alarm/test_diagnostics.py b/tests/components/bosch_alarm/test_diagnostics.py new file mode 100644 index 00000000000..3e10878bd07 --- /dev/null +++ b/tests/components/bosch_alarm/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test the Bosch Alarm diagnostics.""" + +from typing import Any +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +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_panel: AsyncMock, + area: AsyncMock, + model_name: str, + serial_number: str, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + config_flow_data: dict[str, Any], +) -> None: + """Test generating diagnostics for bosch alarm.""" + await setup_integration(hass, mock_config_entry) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + assert diag == snapshot From cf005feace6ed047597bddd4500795c5453bc450 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 3 Apr 2025 13:13:52 +0200 Subject: [PATCH 109/217] Add translation for hassio update entity name (#142090) --- homeassistant/components/hassio/strings.json | 5 +++++ homeassistant/components/hassio/update.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index cc7cfdd5f2c..68a747eb16d 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -265,6 +265,11 @@ "version_latest": { "name": "Newest version" } + }, + "update": { + "update": { + "name": "[%key:component::update::title%]" + } } }, "services": { diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 4ea703e87c3..263cf2dfe13 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -39,7 +39,7 @@ from .entity import ( from .update_helper import update_addon, update_core ENTITY_DESCRIPTION = UpdateEntityDescription( - name="Update", + translation_key="update", key=ATTR_VERSION_LATEST, ) From b2af1084f92f9a67eb8747a815dc3cd15b995e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 3 Apr 2025 15:35:37 +0100 Subject: [PATCH 110/217] Update Whirlpool to 0.20.0 (#142119) --- .../components/whirlpool/manifest.json | 2 +- homeassistant/components/whirlpool/sensor.py | 22 ++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/whirlpool/conftest.py | 16 +++---------- tests/components/whirlpool/test_sensor.py | 23 ++++--------------- 6 files changed, 19 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index ace2e31791d..be47ab619e9 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.19.1"] + "requirements": ["whirlpool-sixth-sense==0.20.0"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index d167e3aa730..44d17228135 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -25,12 +25,12 @@ from .entity import WhirlpoolEntity SCAN_INTERVAL = timedelta(minutes=5) WASHER_TANK_FILL = { - "0": "unknown", - "1": "empty", - "2": "25", - "3": "50", - "4": "100", - "5": "active", + 0: "unknown", + 1: "empty", + 2: "25", + 3: "50", + 4: "100", + 5: "active", } WASHER_DRYER_MACHINE_STATE = { @@ -70,7 +70,7 @@ STATE_DOOR_OPEN = "door_open" def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: """Determine correct states for a washer/dryer.""" - if washer_dryer.get_attribute("Cavity_OpStatusDoorOpen") == "1": + if washer_dryer.get_door_open(): return STATE_DOOR_OPEN machine_state = washer_dryer.get_machine_state() @@ -110,9 +110,7 @@ WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, options=list(WASHER_TANK_FILL.values()), - value_fn=lambda washer: WASHER_TANK_FILL.get( - washer.get_attribute("WashCavity_OpStatusBulkDispense1Level") - ), + value_fn=lambda washer: WASHER_TANK_FILL.get(washer.get_dispense_1_level()), ), ) @@ -224,9 +222,7 @@ class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): if machine_state is MachineState.RunningMainCycle: self._running = True - new_timestamp = now + timedelta( - seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) - ) + new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) if self._value is None or ( isinstance(self._value, datetime) diff --git a/requirements_all.txt b/requirements_all.txt index 51c5e7296a0..6fc6bf9e375 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3067,7 +3067,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83ff3ab8315..04a2880af00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2469,7 +2469,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.19.1 +whirlpool-sixth-sense==0.20.0 # homeassistant.components.whois whois==0.9.27 diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 97550729761..5d063f02924 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -141,18 +141,6 @@ def fixture_mock_aircon_api_instances(mock_aircon1_api, mock_aircon2_api): yield mock_aircon_api -def side_effect_function(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - if args[0] == "Cavity_OpStatusDoorOpen": - return "0" - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - def get_sensor_mock(said: str, data_model: str): """Get a mock of a sensor.""" mock_sensor = mock.Mock(said=said) @@ -165,7 +153,9 @@ def get_sensor_mock(said: str, data_model: str): mock_sensor.get_machine_state.return_value = ( whirlpool.washerdryer.MachineState.Standby ) - mock_sensor.get_attribute.side_effect = side_effect_function + mock_sensor.get_door_open.return_value = False + mock_sensor.get_dispense_1_level.return_value = 3 + mock_sensor.get_time_remaining.return_value = 3540 mock_sensor.get_cycle_status_filling.return_value = False mock_sensor.get_cycle_status_rinsing.return_value = False mock_sensor.get_cycle_status_sensing.return_value = False diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 40c485a5b9f..43a5421391b 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -30,20 +30,6 @@ async def update_sensor_state( return hass.states.get(entity_id) -def side_effect_function_open_door(*args, **kwargs): - """Return correct value for attribute.""" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return 3540 - - if args[0] == "Cavity_OpStatusDoorOpen": - return "1" - - if args[0] == "WashCavity_OpStatusBulkDispense1Level": - return "3" - - return None - - async def test_dryer_sensor_values( hass: HomeAssistant, mock_sensor2_api: MagicMock, entity_registry: er.EntityRegistry ) -> None: @@ -258,7 +244,7 @@ async def test_washer_sensor_values( mock_instance.get_machine_state.return_value = MachineState.Complete mock_instance.attr_value_to_bool.side_effect = None - mock_instance.get_attribute.side_effect = side_effect_function_open_door + mock_instance.get_door_open.return_value = True state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None assert state.state == "door_open" @@ -338,8 +324,7 @@ async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> Non state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle - mock_sensor1_api.get_attribute.side_effect = None - mock_sensor1_api.get_attribute.return_value = "60" + mock_sensor1_api.get_time_remaining.return_value = 60 callback() # Test new timestamp when machine starts a cycle. @@ -348,13 +333,13 @@ async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> Non assert state.state != thetimestamp.isoformat() # Test no timestamp change for < 60 seconds time change. - mock_sensor1_api.get_attribute.return_value = "65" + mock_sensor1_api.get_time_remaining.return_value = 65 callback() state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_attribute.return_value = "125" + mock_sensor1_api.get_time_remaining.return_value = 125 callback() state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") newtime = utc_from_timestamp(as_timestamp(time) + 65) From 53d2347c1041ed2edc38245ef37968f8fd03c44f Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 3 Apr 2025 16:54:23 +0200 Subject: [PATCH 111/217] Fix blocking event loop - daikin (#141442) * fix blocking event loop * create ssl_context directly * update manifest * update manifest.json --- homeassistant/components/daikin/__init__.py | 2 ++ homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 0eaffa39ee9..88a7b71e3ed 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.util.ssl import client_context_no_verify from .const import KEY_MAC, TIMEOUT from .coordinator import DaikinConfigEntry, DaikinCoordinator @@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo key=entry.data.get(CONF_API_KEY), uuid=entry.data.get(CONF_UUID), password=entry.data.get(CONF_PASSWORD), + ssl_context=client_context_no_verify(), ) _LOGGER.debug("Connection to %s successful", host) except TimeoutError as err: diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 86fc804ec92..947fe514747 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.14.1"], + "requirements": ["pydaikin==2.15.0"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6fc6bf9e375..1ee0342dde8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1891,7 +1891,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.14.1 +pydaikin==2.15.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04a2880af00..950307a695a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1548,7 +1548,7 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.14.1 +pydaikin==2.15.0 # homeassistant.components.deako pydeako==0.6.0 From fefa2a9dd6481a4ae79f5dc97f008f2c8a2b81ee Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 3 Apr 2025 18:36:44 +0200 Subject: [PATCH 112/217] Fix fibaro setup (#142201) --- homeassistant/components/fibaro/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 33b2598a636..a4f59d8ab76 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -301,6 +301,7 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) + platform = None if device.enabled and (not device.is_plugin or self._import_plugins): platform = self._map_device_to_platform(device) if platform is None: From 380fb6176bdb30d9d1039dcf686d97a12a228865 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 3 Apr 2025 20:12:24 +0200 Subject: [PATCH 113/217] Add preset mode to SmartThings climate (#142180) * Add preset mode to SmartThings climate * Add preset mode to SmartThings climate --- homeassistant/components/smartthings/climate.py | 13 ++++++++++++- .../fixtures/device_status/da_ac_rac_000001.json | 2 +- .../smartthings/snapshots/test_climate.ambr | 2 +- .../smartthings/snapshots/test_diagnostics.ambr | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 49499732c24..f2f9479584c 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -333,7 +333,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _attr_name = None - _attr_preset_mode = None def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" @@ -545,6 +544,18 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): SWING_OFF, ) + @property + def preset_mode(self) -> str | None: + """Return the preset mode.""" + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + mode = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.AC_OPTIONAL_MODE, + ) + if mode == WINDFREE: + return WINDFREE + return None + def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json index c80fcf9c298..f6cdd661a99 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -473,7 +473,7 @@ "timestamp": "2024-09-10T10:26:28.781Z" }, "acOptionalMode": { - "value": "off", + "value": "windFree", "timestamp": "2025-02-09T09:14:39.642Z" } }, diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 19cfe971d7f..633b02568fc 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -211,7 +211,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': None, + 'preset_mode': 'windFree', 'preset_modes': list([ 'windFree', ]), diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index b9847bf9746..dc7f699de27 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1065,7 +1065,7 @@ 'custom.airConditionerOptionalMode': dict({ 'acOptionalMode': dict({ 'timestamp': '2025-02-09T09:14:39.642Z', - 'value': 'off', + 'value': 'windFree', }), 'supportedAcOptionalMode': dict({ 'timestamp': '2024-09-10T10:26:28.781Z', From 3ed4859db9410fa0f16efe0eaeb9d66f2a69d911 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 3 Apr 2025 20:30:34 +0200 Subject: [PATCH 114/217] Tado bump to 0.18.11 (#142175) * Bump to version 0.18.11 * Adding hassfest files --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 75ddbacc585..eba13d469f3 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.9"] + "requirements": ["python-tado==0.18.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1ee0342dde8..56a79ff1c1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2482,7 +2482,7 @@ python-snoo==0.6.5 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.9 +python-tado==0.18.11 # homeassistant.components.technove python-technove==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 950307a695a..4f37fc47279 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2013,7 +2013,7 @@ python-snoo==0.6.5 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.9 +python-tado==0.18.11 # homeassistant.components.technove python-technove==2.0.0 From 3b2ff38f02c55d12f1755afdd4e7efbd8da11817 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 20:35:34 +0200 Subject: [PATCH 115/217] Use common states for "Low"/"Medium"/"High" in `yolink` (#142139) --- homeassistant/components/yolink/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 8ec7612fd73..b4cfe80f287 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -72,7 +72,11 @@ }, "power_failure_alarm_volume": { "name": "Power failure alarm volume", - "state": { "low": "Low", "medium": "Medium", "high": "High" } + "state": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } }, "power_failure_alarm_beep": { "name": "Power failure alarm beep", From 30e50d261d309276cf23927de0d7e283be3cf36b Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 3 Apr 2025 13:23:59 -0700 Subject: [PATCH 116/217] Made Google Search enable dependent on Assist availability (#141712) * Made Google Search enable dependent on Assist availability * Show error instead of rendering again * Cleanup test code --- .../config_flow.py | 18 +- .../strings.json | 3 + .../test_config_flow.py | 164 +++++++++++++++--- 3 files changed, 151 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index b7753c21bf9..ac6cb696a7d 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -179,28 +179,30 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) + if not ( + user_input.get(CONF_LLM_HASS_API, "none") != "none" + and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True + ): + # Don't allow to save options that enable the Google Seearch tool with an Assist API + return self.async_create_entry(title="", data=user_input) + errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option" # Re-render the options again, now with the recommended options shown/hidden self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = user_input schema = await google_generative_ai_config_option_schema( self.hass, options, self._genai_client ) return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), + step_id="init", data_schema=vol.Schema(schema), errors=errors ) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index b814f89469a..cd7af711d3a 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -43,6 +43,9 @@ "prompt": "Instruct how the LLM should respond. This can be a template." } } + }, + "error": { + "invalid_google_search_option": "Google Search cannot be enabled alongside any Assist capability, this can only be used when Assist is set to \"No control\"." } }, "services": { diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index f7635c0b45e..8fda02b335d 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -39,9 +39,8 @@ from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID from tests.common import MockConfigEntry -@pytest.fixture -def mock_models(): - """Mock the model list API.""" +def get_models_pager(): + """Return a generator that yields the models.""" model_20_flash = Mock( display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], @@ -72,11 +71,7 @@ def mock_models(): yield model_15_pro yield model_10_pro - with patch( - "google.genai.models.AsyncModels.list", - return_value=models_pager(), - ): - yield + return models_pager() async def test_form(hass: HomeAssistant) -> None: @@ -119,8 +114,13 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +def will_options_be_rendered_again(current_options, new_options) -> bool: + """Determine if options will be rendered again.""" + return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED) + + @pytest.mark.parametrize( - ("current_options", "new_options", "expected_options"), + ("current_options", "new_options", "expected_options", "errors"), [ ( { @@ -147,6 +147,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, }, + None, ), ( { @@ -157,6 +158,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_TOP_P: RECOMMENDED_TOP_P, CONF_TOP_K: RECOMMENDED_TOP_K, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_USE_GOOGLE_SEARCH_TOOL: True, }, { CONF_RECOMMENDED: True, @@ -168,6 +170,98 @@ async def test_form(hass: HomeAssistant) -> None: CONF_LLM_HASS_API: "assist", CONF_PROMPT: "", }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + None, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: "assist", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + {CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"}, ), ], ) @@ -175,10 +269,10 @@ async def test_form(hass: HomeAssistant) -> None: async def test_options_switching( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_models, current_options, new_options, expected_options, + errors, ) -> None: """Test the options form.""" with patch("google.genai.models.AsyncModels.get"): @@ -186,24 +280,42 @@ async def test_options_switching( mock_config_entry, options=current_options ) await hass.async_block_till_done() - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - **current_options, - CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], - }, + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + if will_options_be_rendered_again(current_options, new_options): + retry_options = { + **current_options, + CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], + } + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + retry_options, + ) + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + new_options, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - new_options, - ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + if errors is None: + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == expected_options + + else: + assert options["type"] is FlowResultType.FORM + assert options.get("errors", None) == errors @pytest.mark.parametrize( From b9d819e0e515254da230a962434a702ae2a12aa4 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 3 Apr 2025 22:26:56 +0200 Subject: [PATCH 117/217] Do not create a HA mediaplayer for the builtin Music Assistant player (#142192) Do not create a HA mediaplayer for the builtin Music player --- homeassistant/components/music_assistant/media_player.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 01a103f9bc4..08176307829 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -151,6 +151,8 @@ async def async_setup_entry( assert event.object_id is not None if event.object_id in added_ids: return + if not player.expose_to_ha: + return added_ids.add(event.object_id) async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) @@ -159,6 +161,8 @@ async def async_setup_entry( mass_players = [] # add all current players for player in mass.players: + if not player.expose_to_ha: + continue added_ids.add(player.player_id) mass_players.append(MusicAssistantPlayer(mass, player.player_id)) From b84096097c9815f7d5de04561895c891be70a510 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:25:13 +0200 Subject: [PATCH 118/217] Make `calendar.get_events` action description consistent (#142170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes it to match the standard HA style using descriptive wording and changes to "Retrieves …" matching other "Get xyz" actions. --- homeassistant/components/calendar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index c0127c20d05..6612ea5209d 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -74,7 +74,7 @@ }, "get_events": { "name": "Get events", - "description": "Get events on a calendar within a time range.", + "description": "Retrieves events on a calendar within a time range.", "fields": { "start_date_time": { "name": "Start time", From 74d6019f81e8dbc5883731cac8e82823394b0e7f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:47:57 +0200 Subject: [PATCH 119/217] Use common states for "Low"/"Medium"/"High" in `tessie` (#142209) --- homeassistant/components/tessie/strings.json | 54 ++++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index f956e9cefd6..5de18f13140 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -246,81 +246,81 @@ "name": "Seat heater left", "state": { "off": "[%key:common::state::off%]", - "low": "Low", - "medium": "Medium", - "high": "High" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_left": { "name": "Seat cooler left", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "climate_state_seat_fan_front_right": { "name": "Seat cooler right", "state": { "off": "[%key:common::state::off%]", - "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", - "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" } }, "components_customer_preferred_export_rule": { From 2f180c96c848ef105bee6fa261c57563c377fe1c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:48:14 +0200 Subject: [PATCH 120/217] Use common states for "Low"/"Medium"/"High" in `teslemetry` (#142210) --- .../components/teslemetry/strings.json | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 69a99fa52f3..4ff78781c7f 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -262,71 +262,71 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } }, From 7751964db482092d2878a9222d7622288f278e36 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Apr 2025 23:48:33 +0200 Subject: [PATCH 121/217] Use common states for "Low"/"Medium"/"High" in `tesla_fleet` (#142211) --- .../components/tesla_fleet/strings.json | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index c5a03e183e4..e4da161c63d 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -206,71 +206,71 @@ "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { - "high": "High", - "low": "Low", - "medium": "Medium", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_center": { "name": "Seat heater rear center", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_left": { "name": "Seat heater rear left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_rear_right": { "name": "Seat heater rear right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_right": { "name": "Seat heater front right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_left": { "name": "Seat heater third row left", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_seat_heater_third_row_right": { "name": "Seat heater third row right", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", - "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", "off": "[%key:common::state::off%]" } }, "climate_state_steering_wheel_heat_level": { "name": "Steering wheel heater", "state": { - "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", - "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "off": "[%key:common::state::off%]" } }, From b9e17c6cc68f636fe46b2728038ed55fec4ca5cb Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:50:06 +0200 Subject: [PATCH 122/217] Bump pynina to 0.3.5 (#142218) --- homeassistant/components/nina/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 45212c0220b..8bb9a347373 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.4"], + "requirements": ["PyNINA==0.3.5"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 56a79ff1c1c..a297732529e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,7 +70,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f37fc47279..0bacc580831 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -67,7 +67,7 @@ PyMetno==0.13.0 PyMicroBot==0.0.17 # homeassistant.components.nina -PyNINA==0.3.4 +PyNINA==0.3.5 # homeassistant.components.mobile_app # homeassistant.components.owntracks From 7152c8659178ba02d676dfdfbbd3d6dd234b9ade Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 3 Apr 2025 21:09:13 -0400 Subject: [PATCH 123/217] Hide broken ZBT-1 config entries on the hardware page (#142110) * Hide bad ZBT-1 config entries on the hardware page * Set up the bad config entry in the unit test * Roll into a list comprehension * Remove constant changes * Fix condition in unit test --- .../homeassistant_sky_connect/hardware.py | 8 +++++++- .../homeassistant_sky_connect/test_hardware.py | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 2872077111a..9bfa5d16655 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -5,17 +5,21 @@ from __future__ import annotations from homeassistant.components.hardware.models import HardwareInfo, USBInfo from homeassistant.core import HomeAssistant, callback +from .config_flow import HomeAssistantSkyConnectConfigFlow from .const import DOMAIN from .util import get_hardware_variant DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" +EXPECTED_ENTRY_VERSION = ( + HomeAssistantSkyConnectConfigFlow.VERSION, + HomeAssistantSkyConnectConfigFlow.MINOR_VERSION, +) @callback def async_info(hass: HomeAssistant) -> list[HardwareInfo]: """Return board info.""" entries = hass.config_entries.async_entries(DOMAIN) - return [ HardwareInfo( board=None, @@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: url=DOCUMENTATION_URL, ) for entry in entries + # Ignore unmigrated config entries in the hardware page + if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION ] diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index f39e648b0f2..e59a1e7df06 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -28,6 +28,10 @@ CONFIG_ENTRY_DATA_2 = { "firmware": "ezsp", } +CONFIG_ENTRY_DATA_BAD = { + "device": "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_a87b7d75b18beb119fe564a0f320645d-if00-port0", +} + async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info @@ -59,9 +63,20 @@ async def test_hardware_info( minor_version=2, ) config_entry_2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry_2.entry_id) + config_entry_bad = MockConfigEntry( + data=CONFIG_ENTRY_DATA_BAD, + domain=DOMAIN, + options={}, + title="Home Assistant Connect ZBT-1", + unique_id="unique_3", + version=1, + minor_version=2, + ) + config_entry_bad.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry_bad.entry_id) + client = await hass_ws_client(hass) await client.send_json({"id": 1, "type": "hardware/info"}) @@ -97,5 +112,6 @@ async def test_hardware_info( "name": "Home Assistant Connect ZBT-1", "url": "https://skyconnect.home-assistant.io/documentation/", }, + # Bad entry is skipped ] } From 5424fa0a00e0b6904a82c198ddf6e2cc18f79331 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 4 Apr 2025 07:21:40 +0200 Subject: [PATCH 124/217] Bump ical to 9.1.0 (#142197) --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index efce97a0d6f..2bedc7a3163 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.3"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.1.0"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 528552aaa57..90cd5a6d2ac 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==9.0.3"] + "requirements": ["ical==9.1.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 6f117131c20..a630c18c669 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==9.0.3"] + "requirements": ["ical==9.1.0"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 256f5baf0ff..da078395484 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==9.0.3"] + "requirements": ["ical==9.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a297732529e..d8d04d55a7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1196,7 +1196,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.3 +ical==9.1.0 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bacc580831..a92dd9128c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1014,7 +1014,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.0.3 +ical==9.1.0 # homeassistant.components.caldav icalendar==6.1.0 From 95ffa20bd5048041f810b5193a8a8c253b5ff712 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Apr 2025 20:43:04 -1000 Subject: [PATCH 125/217] Bump bleak-esphome to 2.13.1 (#142233) * Bump bleak-esphome to 2.13.0 changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.12.0...v2.13.0 * 13.1 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index ab62c962982..d99de32b09c 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 954968f5e2c..bd81e122981 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.8.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.12.0" + "bleak-esphome==2.13.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d8d04d55a7e..9228d9bb865 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.13.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a92dd9128c0..53979b58c0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.12.0 +bleak-esphome==2.13.1 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From 471b05ff4b81e166a0a4e5ef22b75cbe054b5f07 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Thu, 3 Apr 2025 23:45:52 -0700 Subject: [PATCH 126/217] Improve config entry type hints in NUT (#142237) Fix config entry type hints --- homeassistant/components/nut/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 3c67b28196a..9e1e77a2aaf 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -180,12 +180,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) From 88455702bb08594daca31a18d44737423465a87f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 4 Apr 2025 16:51:09 +1000 Subject: [PATCH 127/217] Slow down polling in Tesla Fleet (#142130) * Slow down polling * Fix tests --- .../components/tesla_fleet/coordinator.py | 2 +- tests/components/tesla_fleet/test_init.py | 88 ++++++++++--------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 50a69258a31..20d2d70b5dc 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState -VEHICLE_INTERVAL_SECONDS = 300 +VEHICLE_INTERVAL_SECONDS = 600 VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS) VEHICLE_WAIT = timedelta(minutes=15) diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index ff103ce03c2..7bd90a3568c 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -1,6 +1,7 @@ """Test the Tesla Fleet init.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, patch from aiohttp import RequestInfo @@ -231,57 +232,58 @@ async def test_vehicle_sleep( freezer: FrozenDateTimeFactory, ) -> None: """Test coordinator refresh with an error.""" - await setup_platform(hass, normal_config_entry) - assert mock_vehicle_data.call_count == 1 - freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Let vehicle sleep, no updates for 15 minutes - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + TEST_INTERVAL = timedelta(seconds=120) - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + with patch( + "homeassistant.components.tesla_fleet.coordinator.VEHICLE_INTERVAL", + TEST_INTERVAL, + ): + await setup_platform(hass, normal_config_entry) + assert mock_vehicle_data.call_count == 1 - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # No polling, call_count should not increase - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 2 + freezer.tick(VEHICLE_WAIT + TEST_INTERVAL) + async_fire_time_changed(hass) + # Let vehicle sleep, no updates for 15 minutes + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Vehicle didn't sleep, go back to normal - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 3 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # No polling, call_count should not increase + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Regular polling - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 4 + freezer.tick(VEHICLE_WAIT) + async_fire_time_changed(hass) + # Vehicle didn't sleep, go back to normal + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 3 - mock_vehicle_data.return_value = VEHICLE_DATA_ALT - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - # Vehicle active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 5 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Regular polling + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 4 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 6 + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Vehicle active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 5 - freezer.tick(VEHICLE_WAIT) - async_fire_time_changed(hass) - # Dont let sleep when active - await hass.async_block_till_done() - assert mock_vehicle_data.call_count == 7 + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 6 + + freezer.tick(TEST_INTERVAL) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 7 # Test Energy Live Coordinator From 1cc8a170e6ce139a2137cd52a557f01e8467fc8b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 4 Apr 2025 16:54:13 +1000 Subject: [PATCH 128/217] Bump teslemetry-stream (#142234) bump stream --- homeassistant/components/teslemetry/binary_sensor.py | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/teslemetry/sensor.py | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 9d14df4501b..d0ba48d281e 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -293,7 +293,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( ), TeslemetryBinarySensorEntityDescription( key="dc_dc_enable", - streaming_key=Signal.DC_DC_ENABLE, + streaming_key=Signal.DCDC_ENABLE, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index a8f1fd0daec..4c21bb017d8 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.1"] } diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b1c6b487bf9..1ba4536ac2b 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -124,6 +124,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", polling=True, + streaming_key=Signal.CHARGER_VOLTAGE, + streaming_firmware="2024.44.32", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, diff --git a/requirements_all.txt b/requirements_all.txt index 9228d9bb865..929d7df13fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2887,7 +2887,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.1 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53979b58c0d..80dbfca65b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2325,7 +2325,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.12 +teslemetry-stream==0.7.1 # homeassistant.components.tessie tessie-api==0.1.1 From 5e04347f135385e4ba0e9c99e5e6b856cba78d40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Apr 2025 20:56:41 -1000 Subject: [PATCH 129/217] Bump bluetooth-data-tools to 1.27.0 (#142221) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.26.5...v1.27.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1b2b0e7267b..d13411b62c4 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.5", + "bluetooth-data-tools==1.27.0", "dbus-fast==2.43.0", "habluetooth==3.37.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 764345710dd..3d8f8793e25 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.5", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.27.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index b88ef3f029a..62ad21eb99a 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.5", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index df24f536527..ceafd8dc4f7 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.5"] + "requirements": ["bluetooth-data-tools==1.27.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 28ff8861052..5d2d6171c6c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.27.0 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 929d7df13fb..3738d44aef0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -639,7 +639,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.27.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80dbfca65b6..b2849725e6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.5 +bluetooth-data-tools==1.27.0 # homeassistant.components.bond bond-async==0.2.1 From b7d9ad1c7d2fbffaf925e69366358c80cdfc2a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 4 Apr 2025 09:12:57 +0200 Subject: [PATCH 130/217] Bump aiohomeconnect to 0.17.0 (#142244) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 62892e7c85b..c5e277c4974 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.16.3"], + "requirements": ["aiohomeconnect==0.17.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 3738d44aef0..ce05b71dc81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller aiohomekit==3.2.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2849725e6f..2cdf3198987 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.3 +aiohomeconnect==0.17.0 # homeassistant.components.homekit_controller aiohomekit==3.2.13 From b5721604b9b59b21b2b14c799366effeb6361caa Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 4 Apr 2025 09:45:36 +0200 Subject: [PATCH 131/217] Use common states for "Low"/"Medium"/"High" in `lg_thinq` (#142253) --- .../components/lg_thinq/strings.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index bb3865254a3..767c984da3a 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -119,9 +119,9 @@ "fan_mode": { "state": { "slow": "Slow", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]" } @@ -390,17 +390,17 @@ "temperature_state": { "name": "[%key:component::sensor::entity_component::temperature::name%]", "state": { - "high": "High", + "high": "[%key:common::state::high%]", "normal": "Good", - "low": "Low" + "low": "[%key:common::state::low%]" } }, "temperature_state_for_location": { "name": "[%key:component::lg_thinq::entity::number::target_temperature_for_location::name%]", "state": { - "high": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::high%]", + "high": "[%key:common::state::high%]", "normal": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::normal%]", - "low": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::low%]" + "low": "[%key:common::state::low%]" } }, "current_state": { @@ -781,8 +781,8 @@ "name": "Battery", "state": { "high": "Full", - "mid": "Medium", - "low": "Low", + "mid": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", "warning": "Empty" } }, @@ -876,9 +876,9 @@ "name": "Speed", "state": { "slow": "[%key:component::lg_thinq::entity::climate::climate_air_conditioner::state_attributes::fan_mode::state::slow%]", - "low": "Low", - "mid": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "mid": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "power": "Turbo", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]", From 9ed8419b5d98192ddcb8d799b739f6164165d293 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 4 Apr 2025 00:48:39 -0700 Subject: [PATCH 132/217] Add device class ENUM and options for sensors in NUT (#142242) Add device class ENUM and options for sensors --- homeassistant/components/nut/sensor.py | 32 ++++++++++++++++-- homeassistant/components/nut/strings.json | 41 ++++++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 1781615b0f9..5822f7f7b02 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -40,13 +40,31 @@ AMBIENT_SENSORS = { "ambient.temperature", "ambient.temperature.status", } -AMBIENT_THRESHOLD_STATUS_OPTIONS = [ +BATTERY_CHARGER_STATUS_OPTIONS = [ + "charging", + "discharging", + "floating", + "resting", + "unknown", + "disabled", + "off", +] +FREQUENCY_STATUS_OPTIONS = [ + "good", + "out-of-range", +] +THRESHOLD_STATUS_OPTIONS = [ "good", "warning-low", "critical-low", "warning-high", "critical-high", ] +UPS_BEEPER_STATUS_OPTIONS = [ + "enabled", + "disabled", + "muted", +] _LOGGER = logging.getLogger(__name__) @@ -64,7 +82,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ambient.humidity.status", translation_key="ambient_humidity_status", device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, ), "ambient.temperature": SensorEntityDescription( @@ -79,7 +97,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ambient.temperature.status", translation_key="ambient_temperature_status", device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, ), "battery.alarm.threshold": SensorEntityDescription( @@ -126,6 +144,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.charger.status": SensorEntityDescription( key="battery.charger.status", translation_key="battery_charger_status", + device_class=SensorDeviceClass.ENUM, + options=BATTERY_CHARGER_STATUS_OPTIONS, ), "battery.current": SensorEntityDescription( key="battery.current", @@ -374,6 +394,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.current.status": SensorEntityDescription( key="input.current.status", translation_key="input_current_status", + device_class=SensorDeviceClass.ENUM, + options=THRESHOLD_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -397,6 +419,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency.status": SensorEntityDescription( key="input.frequency.status", translation_key="input_frequency_status", + device_class=SensorDeviceClass.ENUM, + options=FREQUENCY_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -792,6 +816,8 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", translation_key="ups_beeper_status", + device_class=SensorDeviceClass.ENUM, + options=UPS_BEEPER_STATUS_OPTIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index bda377b9bae..0830b806bd0 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -104,7 +104,18 @@ "battery_charge_low": { "name": "Low battery setpoint" }, "battery_charge_restart": { "name": "Minimum battery to start" }, "battery_charge_warning": { "name": "Warning battery setpoint" }, - "battery_charger_status": { "name": "Charging status" }, + "battery_charger_status": { + "name": "Charging status", + "state": { + "charging": "Charging", + "discharging": "Discharging", + "floating": "Floating", + "resting": "Resting", + "unknown": "Unknown", + "disabled": "Disabled", + "off": "Off" + } + }, "battery_current": { "name": "Battery current" }, "battery_current_total": { "name": "Total battery current" }, "battery_date": { "name": "Battery date" }, @@ -135,10 +146,25 @@ "input_bypass_realpower": { "name": "Input bypass real power" }, "input_bypass_voltage": { "name": "Input bypass voltage" }, "input_current": { "name": "Input current" }, - "input_current_status": { "name": "Input current status" }, + "input_current_status": { + "name": "Input current status", + "state": { + "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]", + "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]", + "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]", + "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]", + "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]" + } + }, "input_frequency": { "name": "Input frequency" }, "input_frequency_nominal": { "name": "Input nominal frequency" }, - "input_frequency_status": { "name": "Input frequency status" }, + "input_frequency_status": { + "name": "Input frequency status", + "state": { + "good": "Good", + "out-of-range": "Out of range" + } + }, "input_l1_current": { "name": "Input L1 current" }, "input_l1_frequency": { "name": "Input L1 line frequency" }, "input_l1_n_voltage": { "name": "Input L1 voltage" }, @@ -194,7 +220,14 @@ "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, "ups_alarm": { "name": "Alarms" }, - "ups_beeper_status": { "name": "Beeper status" }, + "ups_beeper_status": { + "name": "Beeper status", + "state": { + "enabled": "Enabled", + "disabled": "Disabled", + "muted": "Muted" + } + }, "ups_contacts": { "name": "External contacts" }, "ups_delay_reboot": { "name": "UPS reboot delay" }, "ups_delay_shutdown": { "name": "UPS shutdown delay" }, From 93418f587cda661e9b73f8b9d0ed29f081e3123b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 4 Apr 2025 08:52:18 +0100 Subject: [PATCH 133/217] Bump evohome-async to 1.0.5 (#141871) bump client to 1.0.5 --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 44e4cdb1128..21c8874135a 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.4"] + "requirements": ["evohome-async==1.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce05b71dc81..47e30d011e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -901,7 +901,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.4 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cdf3198987..b4d66226845 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -768,7 +768,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.4 +evohome-async==1.0.5 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 From 79fe8650f87bcf7394bb33d4be0a1a310e3ccde9 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 4 Apr 2025 08:54:18 +0100 Subject: [PATCH 134/217] Tweak evohome to handle older TCC-compatible systems (#142226) Handle zone.id == TCS.id --- homeassistant/components/evohome/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index b44dc9791b0..40439c1eb02 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -152,7 +152,7 @@ class EvoZone(EvoChild, EvoClimateEntity): super().__init__(coordinator, evo_device) self._evo_id = evo_device.id - if evo_device.model.startswith("VisionProWifi"): + if evo_device.id == evo_device.tcs.id: # this system does not have a distinct ID for the zone self._attr_unique_id = f"{evo_device.id}z" else: From 5ca044177188f7e922a364332154d0812ad991fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 4 Apr 2025 09:56:40 +0200 Subject: [PATCH 135/217] Do not fetch disconnected Home Connect appliances (#142200) * Do not fetch disconnected Home Connect appliances * Apply suggestions Co-authored-by: Martin Hjelmare * Update docstring --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/coordinator.py | 32 ++++++--- .../home_connect/test_coordinator.py | 71 +++++++++++++++++-- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 5e24ed25abd..fb86bb2edc6 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -74,6 +74,19 @@ class HomeConnectApplianceData: self.settings.update(other.settings) self.status.update(other.status) + @classmethod + def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData: + """Return empty data.""" + return cls( + commands=set(), + events={}, + info=appliance, + options={}, + programs=[], + settings={}, + status={}, + ) + class HomeConnectCoordinator( DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] @@ -362,15 +375,7 @@ class HomeConnectCoordinator( model=appliance.vib, ) if appliance.ha_id not in self.data: - self.data[appliance.ha_id] = HomeConnectApplianceData( - commands=set(), - events={}, - info=appliance, - options={}, - programs=[], - settings={}, - status={}, - ) + self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance) else: self.data[appliance.ha_id].info.connected = appliance.connected old_appliances.remove(appliance.ha_id) @@ -406,6 +411,15 @@ class HomeConnectCoordinator( name=appliance.name, model=appliance.vib, ) + if not appliance.connected: + _LOGGER.debug( + "Appliance %s is not connected, skipping data fetch", + appliance.ha_id, + ) + if appliance_data_to_update: + appliance_data_to_update.info.connected = False + return appliance_data_to_update + return HomeConnectApplianceData.empty(appliance) try: settings = { setting.key: setting diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index e6a3390b284..d3b514bcc17 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -54,6 +54,14 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +INITIAL_FETCH_CLIENT_METHODS = [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", +] + @pytest.fixture def platforms() -> list[str]: @@ -214,15 +222,32 @@ async def test_coordinator_failure_refresh_and_stream( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize( + "appliance", + ["Dishwasher"], + indirect=True, +) +async def test_coordinator_not_fetching_on_disconnected_appliance( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, +) -> None: + """Test that the coordinator does not fetch anything on disconnected appliance.""" + appliance.connected = False + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 + + @pytest.mark.parametrize( "mock_method", - [ - "get_settings", - "get_status", - "get_all_programs", - "get_available_commands", - "get_available_program", - ], + INITIAL_FETCH_CLIENT_METHODS, ) async def test_coordinator_update_failing( mock_method: str, @@ -552,3 +577,35 @@ async def test_devices_updated_on_refresh( assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)}) for appliance in appliances[2:3]: assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_paired_disconnected_devices_not_fetching( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance: HomeAppliance, +) -> None: + """Test that Home Connect API is not fetched after pairing a disconnected device.""" + client.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances([])) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + appliance.connected = False + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) + for method in INITIAL_FETCH_CLIENT_METHODS: + assert getattr(client, method).call_count == 0 From 986095482ff0950df7a6ce98a8e666ca12d8208f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 4 Apr 2025 12:16:19 +0200 Subject: [PATCH 136/217] Tado add diagnostics platform (#142225) * Add diagnostics platform * Fix * Update * Fix --- homeassistant/components/tado/diagnostics.py | 20 +++ .../tado/snapshots/test_diagnostics.ambr | 143 ++++++++++++++++++ tests/components/tado/test_diagnostics.py | 28 ++++ 3 files changed, 191 insertions(+) create mode 100644 homeassistant/components/tado/diagnostics.py create mode 100644 tests/components/tado/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tado/test_diagnostics.py diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py new file mode 100644 index 00000000000..0426707c6a9 --- /dev/null +++ b/homeassistant/components/tado/diagnostics.py @@ -0,0 +1,20 @@ +"""Provides diagnostics for Tado.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import TadoConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: TadoConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Tado config entry.""" + + return { + "data": config_entry.runtime_data.coordinator.data, + "mobile_devices": config_entry.runtime_data.mobile_coordinator.data, + } diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eefb818a88c --- /dev/null +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'data': dict({ + 'device': dict({ + 'WR1': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'serialNo': 'WR1', + 'shortSerialNo': 'WR1', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + 'WR4': dict({ + 'accessPointWiFi': dict({ + 'ssid': 'tado8480', + }), + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'commandTableUploadState': 'FINISHED', + 'connectionState': dict({ + 'timestamp': '2020-03-23T18:30:07.377Z', + 'value': True, + }), + 'currentFwVersion': '59.4', + 'deviceType': 'WR02', + 'duties': list([ + 'ZONE_UI', + 'ZONE_DRIVER', + 'ZONE_LEADER', + ]), + 'serialNo': 'WR4', + 'shortSerialNo': 'WR4', + 'temperatureOffset': dict({ + 'celsius': -1.0, + 'fahrenheit': -1.8, + }), + }), + }), + 'geofence': dict({ + 'presence': 'HOME', + 'presenceLocked': False, + }), + 'weather': dict({ + 'outsideTemperature': dict({ + 'celsius': 7.46, + 'fahrenheit': 45.43, + 'precision': dict({ + 'celsius': 0.01, + 'fahrenheit': 0.01, + }), + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'TEMPERATURE', + }), + 'solarIntensity': dict({ + 'percentage': 2.1, + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'PERCENTAGE', + }), + 'weatherState': dict({ + 'timestamp': '2020-12-22T08:13:13.652Z', + 'type': 'WEATHER_STATE', + 'value': 'FOGGY', + }), + }), + 'zone': dict({ + '1': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=1, current_temp=20.65, connection=None, current_temp_timestamp='2020-03-10T07:44:11.947Z', current_humidity=45.2, current_humidity_timestamp='2020-03-10T07:44:11.947Z', is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.5, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp='2020-03-10T07:47:45.978Z', ac_power=None, heating_power=None, heating_power_percentage=0.0, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '2': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=2, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=65.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '3': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=3, current_temp=24.76, connection=None, current_temp_timestamp='2020-03-05T03:57:38.850Z', current_humidity=60.9, current_humidity_timestamp='2020-03-05T03:57:38.850Z', is_away=False, current_hvac_action='COOLING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='COOL', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=17.78, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-05T04:01:07.162Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '4': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=4, current_temp=None, connection=None, current_temp_timestamp=None, current_humidity=None, current_humidity_timestamp=None, is_away=False, current_hvac_action='IDLE', current_fan_speed=None, current_fan_level=None, current_hvac_mode='HEATING', current_swing_mode='OFF', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=30.0, available=True, power='ON', link='ONLINE', ac_power_timestamp=None, heating_power_timestamp=None, ac_power=None, heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='TADO_MODE', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '5': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=5, current_temp=20.88, connection=None, current_temp_timestamp='2020-03-28T02:09:27.830Z', current_humidity=42.3, current_humidity_timestamp='2020-03-28T02:09:27.830Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level=None, current_hvac_mode='SMART_SCHEDULE', current_swing_mode='ON', current_vertical_swing_mode='OFF', current_horizontal_swing_mode='OFF', target_temp=20.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2020-03-27T23:02:22.260Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type=None, overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + '6': dict({ + '__type': "", + 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", + }), + }), + }), + 'mobile_devices': dict({ + 'mobile_device': dict({ + '123456': dict({ + 'deviceMetadata': dict({ + 'locale': 'nl', + 'model': 'Samsung', + 'osVersion': '14', + 'platform': 'Android', + }), + 'id': 123456, + 'name': 'Home', + 'settings': dict({ + 'geoTrackingEnabled': False, + 'onDemandLogRetrievalEnabled': False, + 'pushNotifications': dict({ + 'awayModeReminder': True, + 'energyIqReminder': False, + 'energySavingsReportReminder': True, + 'homeModeReminder': True, + 'incidentDetection': True, + 'lowBatteryReminder': True, + 'openWindowReminder': True, + }), + 'specialOffersEnabled': False, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/tado/test_diagnostics.py b/tests/components/tado/test_diagnostics.py new file mode 100644 index 00000000000..3a4f04b0a4c --- /dev/null +++ b/tests/components/tado/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test the Tado component diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.tado.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + await async_init_integration(hass) + + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot(exclude=props("created_at", "modified_at")) From a407a3c98d2429b23cb41695f48c4d580c62fb4b Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 4 Apr 2025 12:32:14 +0200 Subject: [PATCH 137/217] Fix skyconnect tests (#142262) fix tests --- tests/components/zha/test_repairs.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index af81ac0d586..0ff863f0c45 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -49,7 +49,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "SkyConnect v1.0", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant SkyConnect", @@ -66,7 +67,8 @@ def test_detect_radio_hardware(hass: HomeAssistant) -> None: "product": "Home Assistant Connect ZBT-1", "firmware": "ezsp", }, - version=2, + version=1, + minor_version=4, domain=SKYCONNECT_DOMAIN, options={}, title="Home Assistant Connect ZBT-1", From a05785529fc39fe64301ea735476e7625d7ebdcb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:39:54 +0200 Subject: [PATCH 138/217] Fix RuntimeWarning in homeassistant_hardware (#142269) --- .../homeassistant_hardware/test_config_flow_failures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 251c4743bfe..38c2696a62a 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -45,6 +45,7 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): STEP_PICK_FIRMWARE_THREAD, ], ) +@pytest.mark.usefixtures("addon_store_info") async def test_config_flow_cannot_probe_firmware( next_step: str, hass: HomeAssistant ) -> None: From 5eea5858eadac6591cf417be90f7cb45b7a97ff3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 4 Apr 2025 16:38:56 +0200 Subject: [PATCH 139/217] Update frontend to 20250404.0 (#142274) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_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 4cab8375d1b..140d90c5dbe 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250401.0"] + "requirements": ["home-assistant-frontend==20250404.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5d2d6171c6c..fbf10ce142a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250401.0 +home-assistant-frontend==20250404.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 47e30d011e5..f0080b0aa8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250401.0 +home-assistant-frontend==20250404.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4d66226845..bb046586b53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250401.0 +home-assistant-frontend==20250404.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From f4ed9edec65ec8408eb7bdd4bed2b854fb907175 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 4 Apr 2025 08:06:37 -0700 Subject: [PATCH 140/217] Use common state strings in NUT (#142284) User common state strings --- homeassistant/components/nut/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 0830b806bd0..fe06bef3903 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -107,12 +107,12 @@ "battery_charger_status": { "name": "Charging status", "state": { - "charging": "Charging", - "discharging": "Discharging", + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", "floating": "Floating", "resting": "Resting", "unknown": "Unknown", - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "off": "Off" } }, @@ -223,8 +223,8 @@ "ups_beeper_status": { "name": "Beeper status", "state": { - "enabled": "Enabled", - "disabled": "Disabled", + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]", "muted": "Muted" } }, From 61d2c9335f169b9a3644a3625e99cfc35fb75c49 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 4 Apr 2025 17:52:06 +0200 Subject: [PATCH 141/217] Bump pySmartThings to 3.0.2 (#142257) Co-authored-by: Robert Resch --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 2af3e5c193b..dda7ef53cf5 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.0.1"] + "requirements": ["pysmartthings==3.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f0080b0aa8e..ef84c29eeef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2319,7 +2319,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.1 +pysmartthings==3.0.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb046586b53..dc7813b2186 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.1 +pysmartthings==3.0.2 # homeassistant.components.smarty pysmarty2==0.10.2 From 3c60bff7dc737d947af995c0008f3b5e7373325f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 4 Apr 2025 17:57:22 +0200 Subject: [PATCH 142/217] Add support for Matter EVSE devicetype (#137189) * Binary sensors * Add tests * Update strings * Enable testing * Add command_timeout to MatterEntityDescription * Add entities * Update strings.json * Add sensors * Add tests * Move command_timeout keyword to MatterGenericCommandSwitch * Icons * Update snapshots * Add tests for switch entity * Fix switch tests * Rename states * Update strings.json * Update snapshot * Rename charging switch * Remove MatterEntity * Update strings.json * Update snapshots * Update snaphots 2/2 * Update strings * Update test binary --- .../components/matter/binary_sensor.py | 57 ++ homeassistant/components/matter/entity.py | 1 + homeassistant/components/matter/icons.json | 12 + homeassistant/components/matter/sensor.py | 92 +++ homeassistant/components/matter/strings.json | 48 ++ homeassistant/components/matter/switch.py | 91 +++ tests/components/matter/conftest.py | 1 + .../fixtures/nodes/silabs_evse_charging.json | 580 ++++++++++++++++++ .../matter/snapshots/test_binary_sensor.ambr | 144 +++++ .../matter/snapshots/test_select.ambr | 122 ++++ .../matter/snapshots/test_sensor.ambr | 317 ++++++++++ .../matter/snapshots/test_switch.ambr | 47 ++ tests/components/matter/test_binary_sensor.py | 50 ++ tests/components/matter/test_sensor.py | 68 ++ tests/components/matter/test_switch.py | 44 ++ 15 files changed, 1674 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/silabs_evse_charging.json diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b5665e5d47a..a55df58cac7 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -265,4 +265,61 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectCOAlarm,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseChargingStatusSensor", + translation_key="evse_charging_status", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: False, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvsePlugStateSensor", + translation_key="evse_plug_state", + device_class=BinarySensorDeviceClass.PLUG, + measurement_to_ha={ + clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True, + clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: True, + clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False, + clusters.EnergyEvse.Enums.StateEnum.kFault: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.State,), + allow_multi=True, # also used for sensor entity + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="EnergyEvseSupplyStateSensor", + translation_key="evse_supply_charging_state", + device_class=BinarySensorDeviceClass.RUNNING, + measurement_to_ha={ + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, + }.get, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.EnergyEvse.Attributes.SupplyState,), + allow_multi=True, # also used for sensor entity + ), ] diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 96696193466..fded57d34f5 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -61,6 +61,7 @@ class MatterEntityDescription(EntityDescription): # convert the value from the primary attribute to the value used by HA measurement_to_ha: Callable[[Any], Any] | None = None ha_to_native_value: Callable[[Any], Any] | None = None + command_timeout: int | None = None class MatterEntity(Entity): diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index f9217cabcc4..fed51708870 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -71,6 +71,15 @@ }, "battery_replacement_description": { "default": "mdi:battery-sync-outline" + }, + "evse_state": { + "default": "mdi:ev-station" + }, + "evse_supply_state": { + "default": "mdi:ev-station" + }, + "evse_fault_state": { + "default": "mdi:ev-station" } }, "switch": { @@ -80,6 +89,9 @@ "on": "mdi:lock", "off": "mdi:lock-off" } + }, + "evse_charging_switch": { + "default": "mdi:ev-station" } } } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 10f8db275f5..82d8ec1727c 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -77,6 +77,25 @@ OPERATIONAL_STATE_MAP = { clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } +EVSE_FAULT_STATE_MAP = { + clusters.EnergyEvse.Enums.FaultStateEnum.kNoError: "no_error", + clusters.EnergyEvse.Enums.FaultStateEnum.kMeterFailure: "meter_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverVoltage: "over_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kUnderVoltage: "under_voltage", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverCurrent: "over_current", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactWetFailure: "contact_wet_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kContactDryFailure: "contact_dry_failure", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerLoss: "power_loss", + clusters.EnergyEvse.Enums.FaultStateEnum.kPowerQuality: "power_quality", + clusters.EnergyEvse.Enums.FaultStateEnum.kPilotShortCircuit: "pilot_short_circuit", + clusters.EnergyEvse.Enums.FaultStateEnum.kEmergencyStop: "emergency_stop", + clusters.EnergyEvse.Enums.FaultStateEnum.kEVDisconnected: "ev_disconnected", + clusters.EnergyEvse.Enums.FaultStateEnum.kWrongPowerSupply: "wrong_power_supply", + clusters.EnergyEvse.Enums.FaultStateEnum.kLiveNeutralSwap: "live_neutral_swap", + clusters.EnergyEvse.Enums.FaultStateEnum.kOverTemperature: "over_temperature", + clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other", +} + async def async_setup_entry( hass: HomeAssistant, @@ -904,4 +923,77 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseFaultState", + translation_key="evse_fault_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(EVSE_FAULT_STATE_MAP.values()), + measurement_to_ha=EVSE_FAULT_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.FaultState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseCircuitCapacity", + translation_key="evse_circuit_capacity", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.CircuitCapacity,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMinimumChargeCurrent", + translation_key="evse_min_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MinimumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseMaximumChargeCurrent", + translation_key="evse_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.MaximumChargeCurrent,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseUserMaximumChargeCurrent", + translation_key="evse_user_max_charge_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 81a9a4ba796..4fa49f887d9 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -76,6 +76,15 @@ }, "muted": { "name": "Muted" + }, + "evse_charging_status": { + "name": "Charging status" + }, + "evse_plug": { + "name": "Plug state" + }, + "evse_supply_charging_state": { + "name": "Supply charging state" } }, "button": { @@ -278,6 +287,42 @@ }, "current_phase": { "name": "Current phase" + }, + "evse_fault_state": { + "name": "Fault state", + "state": { + "no_error": "OK", + "meter_failure": "Meter failure", + "over_voltage": "Overvoltage", + "under_voltage": "Undervoltage", + "over_current": "Overcurrent", + "contact_wet_failure": "Contact wet failure", + "contact_dry_failure": "Contact dry failure", + "power_loss": "Power loss", + "power_quality": "Power quality", + "pilot_short_circuit": "Pilot short circuit", + "emergency_stop": "Emergency stop", + "ev_disconnected": "EV disconnected", + "wrong_power_supply": "Wrong power supply", + "live_neutral_swap": "Live/neutral swap", + "over_temperature": "Overtemperature", + "other": "Other fault" + } + }, + "evse_circuit_capacity": { + "name": "Circuit capacity" + }, + "evse_charge_current": { + "name": "Charge current" + }, + "evse_min_charge_current": { + "name": "Min charge current" + }, + "evse_max_charge_current": { + "name": "Max charge current" + }, + "evse_user_max_charge_current": { + "name": "User max charge current" } }, "switch": { @@ -289,6 +334,9 @@ }, "child_lock": { "name": "Child lock" + }, + "evse_charging_switch": { + "name": "Enable charging" } }, "vacuum": { diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index af4803af9a1..870a9098492 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import ClusterCommand, NullValue from matter_server.client.models import device_types from homeassistant.components.switch import ( @@ -22,6 +24,13 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +EVSE_SUPPLY_STATE_MAP = { + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, + clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False, +} + async def async_setup_entry( hass: HomeAssistant, @@ -58,6 +67,66 @@ class MatterSwitch(MatterEntity, SwitchEntity): ) +class MatterGenericCommandSwitch(MatterSwitch): + """Representation of a Matter switch.""" + + entity_description: MatterGenericCommandSwitchEntityDescription + + _platform_translation_key = "switch" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + if self.entity_description.on_command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.on_command(), + self.entity_description.command_timeout, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + if self.entity_description.off_command: + await self.send_device_command( + self.entity_description.off_command(), + self.entity_description.command_timeout, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value + + async def send_device_command( + self, + command: ClusterCommand, + command_timeout: int | None = None, + **kwargs: Any, + ) -> None: + """Send device command with timeout.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=command_timeout, + **kwargs, + ) + + +@dataclass(frozen=True) +class MatterGenericCommandSwitchEntityDescription( + SwitchEntityDescription, MatterEntityDescription +): + """Describe Matter Generic command Switch entities.""" + + # command: a custom callback to create the command to send to the device + on_command: Callable[[], Any] | None = None + off_command: Callable[[], Any] | None = None + command_timeout: int | None = None + + @dataclass(frozen=True) class MatterNumericSwitchEntityDescription( SwitchEntityDescription, MatterEntityDescription @@ -194,4 +263,26 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterGenericCommandSwitchEntityDescription( + key="EnergyEvseChargingSwitch", + translation_key="evse_charging_switch", + on_command=lambda: clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + off_command=clusters.EnergyEvse.Commands.Disable, + command_timeout=3000, + measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get, + ), + entity_class=MatterGenericCommandSwitch, + required_attributes=( + clusters.EnergyEvse.Attributes.SupplyState, + clusters.EnergyEvse.Attributes.AcceptedCommandList, + ), + value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id, + allow_multi=True, + ), ] diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d7429f6087d..a085a1e3540 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -104,6 +104,7 @@ async def integration_fixture( "pressure_sensor", "room_airconditioner", "silabs_dishwasher", + "silabs_evse_charging", "silabs_laundrywasher", "smoke_detector", "switch_unit", diff --git a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json new file mode 100644 index 00000000000..3188ba81ad6 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json @@ -0,0 +1,580 @@ +{ + "node_id": 23, + "date_commissioned": "2024-12-17T18:14:53.210190", + "last_interview": "2024-12-17T18:14:53.211611", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 45, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "evse", + "0/40/4": 32769, + "0/40/5": "evse", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/15": "TEST_SN", + "0/40/18": "evse", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/8": [0], + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "docker0", + "1": false, + "2": null, + "3": null, + "4": "AkI9NTnB", + "5": ["rBEAAQ=="], + "6": [""], + "7": 0 + }, + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwp/F0T", + "5": ["wKgBpw=="], + "6": [ + "KgEOCgKzOZCNB+q+Uz0I9w==", + "KgEOCgKzOZC/O1Ew1WvS4A==", + "/oAAAAAAAADml3Ozl7GZug==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 10129, + "0/51/8": true, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRFxgkBwEkCAEwCUEECp4PASYUFk/DwQqGNBikYdiBRDJZbrfF4AYK8Y9jOeIpx7Xy+giJhmTpAVZ662hwszsFDGULGY/owXtMrqTxEDcKNQEoARgkAgE2AwQCBAEYMAQUqBmxO16fPQhbf33Gb2XwQ+NkXpswBRTx8+4bdkuqlxInfB5LXkhRBBvS2hgwC0A8aefsLm663Vuy+TkSvn/oLhRqt2phrG+i5aM5o15xiWDjnNVdUYpT09+K0mgVoMdFuFsmoWQxQh6jahaFJzUgGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEGp55xGRB0FBQ3Yw7ayQSzVtYA0BtCJFm9vRRcdr+nk0cuGX6zrUowSYOO/qiRBEACcCNNSqKh+DpRm2uVLOtaDcKNQEpARgkAmAwBBTx8+4bdkuqlxInfB5LXkhRBBvS2jAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQIw/6q5ILMNdOMcSif8HNbEgpjBeaBMfUpzOJFCRPM16sv1xiq3mALZj0u+iG8lUJEvDJOFKPoBvsOubwIwRgAQY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BMeyHMXjJpVWF9saehBu7pZLTwdopKZTl5JdhU0/ozZ/sk1paVFE1U8OtuZqM/S/4W/fnkCnUrQ/Xcs7Ddy0hPE=", + "2": 65521, + "3": 1, + "4": 23, + "5": "HA_test", + "254": 1 + }, + { + "1": "BBF47gm4BEBA6LXQluAHjn6P3+MZKrhuMcJligg1xcBM7X++F7GsZFh4hYAhdmD9HHwhtZxH2c85aAzbpikViwI=", + "2": 65521, + "3": 1, + "4": 100, + "5": "", + "254": 2 + } + ], + "0/62/2": 16, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEx7IcxeMmlVYX2xp6EG7ulktPB2ikplOXkl2FTT+jNn+yTWlpUUTVTw625moz9L/hb9+eQKdStD9dyzsN3LSE8TcKNQEpARgkAmAwBBSMUxuvFOVkFbJPALb0kMnityi6jzAFFIxTG68U5WQVsk8AtvSQyeK3KLqPGDALQPBVUg+OBUWl1pe/k55ZigAZl3lfBP1Qd5zQP4AUB45mNTzdli8DRCj+h7cIs3JHQQPlUaRvG5xUoBZ+C7Gg2sQY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEEXjuCbgEQEDotdCW4AeOfo/f4xkquG4xwmWKCDXFwEztf74XsaxkWHiFgCF2YP0cfCG1nEfZzzloDNumKRWLAjcKNQEpARgkAmAwBBQD3rx0jOdkiCPt06hxW7Z2jJBPXTAFFAPevHSM52SII+3TqHFbtnaMkE9dGDALQL+L3Zc6En6Ionk6WIz+lM50iwOEzTi9VwyYQRUdtO99T8jRX52+Olh6zcUtWQuYO2XYiH2OZ8lM4guqqnS8U4UY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 1293, + "1": 1 + }, + { + "0": 1292, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 17, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 144, 145, 152, 153, 156, 157, 159], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 0, + "1/47/2": "Primary Mains Power", + "1/47/5": 0, + "1/47/7": 230000, + "1/47/8": 32000, + "1/47/31": [1], + "1/47/65532": 1, + "1/47/65533": 3, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [0, 1, 2, 5, 7, 8, 31, 65528, 65529, 65531, 65532, 65533], + "1/144/0": 2, + "1/144/1": 3, + "1/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "1/144/3": [], + "1/144/4": null, + "1/144/5": null, + "1/144/6": null, + "1/144/7": null, + "1/144/8": null, + "1/144/9": null, + "1/144/10": null, + "1/144/11": null, + "1/144/12": null, + "1/144/13": null, + "1/144/14": null, + "1/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "1/144/17": null, + "1/144/18": null, + "1/144/65532": 31, + "1/144/65533": 1, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "1/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 98440650424323, + "1": 98442759724168, + "2": 0, + "3": 0, + "5": 140728898420739, + "6": 98440650424355 + } + ] + }, + "1/145/1": null, + "1/145/2": null, + "1/145/3": null, + "1/145/4": null, + "1/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "1/145/65532": 15, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/152/0": 0, + "1/152/1": false, + "1/152/2": 1, + "1/152/3": 1200000, + "1/152/4": 7600000, + "1/152/5": null, + "1/152/6": null, + "1/152/7": 0, + "1/152/65532": 123, + "1/152/65533": 4, + "1/152/65528": [], + "1/152/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/152/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "1/153/0": 3, + "1/153/1": 1, + "1/153/2": 0, + "1/153/3": null, + "1/153/5": 32000, + "1/153/6": 2000, + "1/153/7": 30000, + "1/153/9": 32000, + "1/153/10": 600, + "1/153/35": null, + "1/153/36": null, + "1/153/37": null, + "1/153/38": null, + "1/153/39": null, + "1/153/64": 2, + "1/153/65": 0, + "1/153/66": 0, + "1/153/65532": 9, + "1/153/65533": 3, + "1/153/65528": [0], + "1/153/65529": [1, 2, 5, 6, 7, 4], + "1/153/65531": [ + 0, 1, 2, 3, 5, 6, 7, 9, 10, 35, 36, 37, 38, 39, 64, 65, 66, 65528, 65529, + 65531, 65532, 65533 + ], + "1/156/65532": 1, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65528, 65529, 65531, 65532, 65533], + "1/157/0": [ + { + "0": "Manual", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Auto-scheduled", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Solar", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Auto-scheduled with Solar charging", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16386 + } + ] + } + ], + "1/157/1": 1, + "1/157/65532": 0, + "1/157/65533": 2, + "1/157/65528": [1], + "1/157/65529": [0], + "1/157/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/159/0": [ + { + "0": "No energy management (forecast only)", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Device optimizes (no local or grid control)", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Optimized within building", + "1": 2, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + } + ] + }, + { + "0": "Optimized for grid", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 16387 + } + ] + }, + { + "0": "Optimized for grid and building", + "1": 4, + "2": [ + { + "1": 16386 + }, + { + "1": 16385 + }, + { + "1": 16387 + } + ] + } + ], + "1/159/1": 3, + "1/159/65532": 0, + "1/159/65533": 2, + "1/159/65528": [1], + "1/159/65529": [0], + "1/159/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index c8de905d03f..ec5317ba808 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -383,6 +383,150 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.evse_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_status', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingStatusSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'evse Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.evse_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_plug_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvsePlugStateSensor-153-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'evse Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.evse_supply_charging_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply charging state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_supply_charging_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'evse Supply charging state', + }), + 'context': , + 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[smoke_detector][binary_sensor.smoke_sensor_battery_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 772ee297e13..8ad579214d0 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1543,6 +1543,128 @@ 'state': 'previous', }) # --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_energy_management_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy management mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_energy_management_mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterDeviceEnergyManagementMode-159-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_energy_management_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Energy management mode', + 'options': list([ + 'No energy management (forecast only)', + 'Device optimizes (no local or grid control)', + 'Optimized within building', + 'Optimized for grid', + 'Optimized for grid and building', + ]), + }), + 'context': , + 'entity_id': 'select.evse_energy_management_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Optimized for grid', + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.evse_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-MatterEnergyEvseMode-157-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_evse_charging][select.evse_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Mode', + 'options': list([ + 'Manual', + 'Auto-scheduled', + 'Solar', + 'Auto-scheduled with Solar charging', + ]), + }), + 'context': , + 'entity_id': 'select.evse_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Auto-scheduled', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index cb26f1d8e70..b3395551d74 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2866,6 +2866,323 @@ 'state': '120.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Circuit capacity', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_circuit_capacity', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseCircuitCapacity-153-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_circuit_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Circuit capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_circuit_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_fault_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_fault_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseFaultState-153-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Fault state', + 'options': list([ + 'no_error', + 'meter_failure', + 'over_voltage', + 'under_voltage', + 'over_current', + 'contact_wet_failure', + 'contact_dry_failure', + 'power_loss', + 'power_quality', + 'pilot_short_circuit', + 'emergency_stop', + 'ev_disconnected', + 'wrong_power_supply', + 'live_neutral_swap', + 'over_temperature', + 'other', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_fault_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.evse_max_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMaximumChargeCurrent-153-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.evse_min_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Min charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_min_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseMinimumChargeCurrent-153-6', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_min_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse Min charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_min_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User max charge current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_user_max_charge_current', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseUserMaximumChargeCurrent-153-9', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'evse User max charge current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_user_max_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.0', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index ebf43117846..d60a2933e6f 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -334,6 +334,53 @@ 'state': 'off', }) # --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.evse_enable_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Enable charging', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'evse_charging_switch', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseChargingSwitch-153-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[silabs_evse_charging][switch.evse_enable_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'evse Enable charging', + }), + 'context': , + 'entity_id': 'switch.evse_enable_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch_unit][switch.mock_switchunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index cddee975ac8..acd150d9131 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -147,3 +147,53 @@ async def test_optional_sensor_from_featuremap( ) state = hass.states.get(entity_id) assert state is None + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # Test StateEnum value with binary_sensor.evse_charging_status + entity_id = "binary_sensor.evse_charging_status" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to PluggedInDemand state + set_node_attribute(matter_node, 1, 153, 0, 2) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 2) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test StateEnum value with binary_sensor.evse_plug + entity_id = "binary_sensor.evse_plug" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to NotPluggedIn state + set_node_attribute(matter_node, 1, 153, 0, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/0", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # Test SupplyStateEnum value with binary_sensor.evse_supply_charging + entity_id = "binary_sensor.evse_supply_charging_state" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + # switch to Disabled state + set_node_attribute(matter_node, 1, 153, 1, 0) + await trigger_subscription_callback( + hass, matter_client, data=(matter_node.node_id, "1/153/1", 0) + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "off" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 251aab73e3b..bcdb573b3c8 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -399,3 +399,71 @@ async def test_list_sensor( state = hass.states.get("sensor.laundrywasher_current_phase") assert state assert state.state == "rinse" + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + # EnergyEvseFaultState + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "no_error" + + set_node_attribute(matter_node, 1, 153, 2, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_fault_state") + assert state + assert state.state == "over_current" + + # EnergyEvseCircuitCapacity + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 5, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_circuit_capacity") + assert state + assert state.state == "63.0" + + # EnergyEvseMinimumChargeCurrent + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "2.0" + + set_node_attribute(matter_node, 1, 153, 6, 5000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_min_charge_current") + assert state + assert state.state == "5.0" + + # EnergyEvseMaximumChargeCurrent + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "30.0" + + set_node_attribute(matter_node, 1, 153, 7, 20000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_max_charge_current") + assert state + assert state.state == "20.0" + + # EnergyEvseUserMaximumChargeCurrent + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "32.0" + + set_node_attribute(matter_node, 1, 153, 9, 63000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.evse_user_max_charge_current") + assert state + assert state.state == "63.0" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index e82848fcc3a..f294cd31a26 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute @@ -188,3 +189,46 @@ async def test_matter_exception_on_command( }, blocking=True, ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_evse_charging"]) +async def test_evse_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test evse sensors.""" + state = hass.states.get("switch.evse_enable_charging") + assert state + assert state.state == "on" + # test switch service + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.Disable(), + timed_request_timeout_ms=3000, + ) + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.evse_enable_charging"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.EnergyEvse.Commands.EnableCharging( + chargingEnabledUntil=NullValue, + minimumChargeCurrent=0, + maximumChargeCurrent=0, + ), + timed_request_timeout_ms=3000, + ) From 9c538d1e227cfdb87b36c8855e3f5b09e8cf9733 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Fri, 4 Apr 2025 21:18:09 +0200 Subject: [PATCH 143/217] Bump forecast-solar lib to v4.1.0 (#142280) Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/forecast_solar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index 1eb9c98701d..769bda56adc 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/forecast_solar", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["forecast-solar==4.0.0"] + "requirements": ["forecast-solar==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef84c29eeef..22287c8c577 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -954,7 +954,7 @@ fnv-hash-fast==1.4.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.1.0 # homeassistant.components.fortios fortiosapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc7813b2186..0e914b91275 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -814,7 +814,7 @@ fnv-hash-fast==1.4.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast-solar==4.0.0 +forecast-solar==4.1.0 # homeassistant.components.freebox freebox-api==1.2.2 From 64e17356473cf4daf6859f4978de910ba20ca764 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 4 Apr 2025 21:19:15 +0200 Subject: [PATCH 144/217] Fix circular mean by always storing and using the weighted one (#142208) * Fix circular mean by always storing and using the weighted one * fix * Fix test --- .../components/recorder/statistics.py | 86 +++++++++++-------- homeassistant/components/sensor/recorder.py | 4 +- tests/components/sensor/test_recorder.py | 46 +++++----- 3 files changed, 76 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2507a66899e..80c0028ef7a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -139,14 +139,13 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: # in Python. # https://en.wikipedia.org/wiki/Circular_mean radians = func.radians(table.mean) + weighted_sum_sin = func.sum(func.sin(radians) * table.mean_weight) + weighted_sum_cos = func.sum(func.cos(radians) * table.mean_weight) weight = func.sqrt( - func.power(func.sum(func.sin(radians) * table.mean_weight), 2) - + func.power(func.sum(func.cos(radians) * table.mean_weight), 2) + func.power(weighted_sum_sin, 2) + func.power(weighted_sum_cos, 2) ) return ( - func.degrees( - func.atan2(func.sum(func.sin(radians)), func.sum(func.cos(radians))) - ).label("mean"), + func.degrees(func.atan2(weighted_sum_sin, weighted_sum_cos)).label("mean"), weight.label("mean_weight"), ) @@ -240,18 +239,20 @@ DEG_TO_RAD = math.pi / 180 RAD_TO_DEG = 180 / math.pi -def weighted_circular_mean(values: Iterable[tuple[float, float]]) -> float: - """Return the weighted circular mean of the values.""" - sin_sum = sum(math.sin(x * DEG_TO_RAD) * weight for x, weight in values) - cos_sum = sum(math.cos(x * DEG_TO_RAD) * weight for x, weight in values) - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 +def weighted_circular_mean( + values: Iterable[tuple[float, float]], +) -> tuple[float, float]: + """Return the weighted circular mean and the weight of the values.""" + weighted_sin_sum, weighted_cos_sum = 0.0, 0.0 + for x, weight in values: + rad_x = x * DEG_TO_RAD + weighted_sin_sum += math.sin(rad_x) * weight + weighted_cos_sum += math.cos(rad_x) * weight - -def circular_mean(values: list[float]) -> float: - """Return the circular mean of the values.""" - sin_sum = sum(math.sin(x * DEG_TO_RAD) for x in values) - cos_sum = sum(math.cos(x * DEG_TO_RAD) for x in values) - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + return ( + (RAD_TO_DEG * math.atan2(weighted_sin_sum, weighted_cos_sum)) % 360, + math.sqrt(weighted_sin_sum**2 + weighted_cos_sum**2), + ) _LOGGER = logging.getLogger(__name__) @@ -300,6 +301,7 @@ class StatisticsRow(BaseStatisticsRow, total=False): min: float | None max: float | None mean: float | None + mean_weight: float | None change: float | None @@ -1023,7 +1025,7 @@ def _reduce_statistics( _want_sum = "sum" in types for statistic_id, stat_list in stats.items(): max_values: list[float] = [] - mean_values: list[float] = [] + mean_values: list[tuple[float, float]] = [] min_values: list[float] = [] prev_stat: StatisticsRow = stat_list[0] fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} @@ -1039,12 +1041,15 @@ def _reduce_statistics( } if _want_mean: row["mean"] = None + row["mean_weight"] = None if mean_values: match metadata[statistic_id][1]["mean_type"]: case StatisticMeanType.ARITHMETIC: - row["mean"] = mean(mean_values) + row["mean"] = mean([x[0] for x in mean_values]) case StatisticMeanType.CIRCULAR: - row["mean"] = circular_mean(mean_values) + row["mean"], row["mean_weight"] = ( + weighted_circular_mean(mean_values) + ) mean_values.clear() if _want_min: row["min"] = min(min_values) if min_values else None @@ -1063,7 +1068,8 @@ def _reduce_statistics( max_values.append(_max) if _want_mean: if (_mean := statistic.get("mean")) is not None: - mean_values.append(_mean) + _mean_weight = statistic.get("mean_weight") or 0.0 + mean_values.append((_mean, _mean_weight)) if _want_min and (_min := statistic.get("min")) is not None: min_values.append(_min) prev_stat = statistic @@ -1385,7 +1391,7 @@ def _get_max_mean_min_statistic( match metadata[1]["mean_type"]: case StatisticMeanType.CIRCULAR: if circular_means := max_mean_min["circular_means"]: - mean_value = weighted_circular_mean(circular_means) + mean_value = weighted_circular_mean(circular_means)[0] case StatisticMeanType.ARITHMETIC: if (mean_value := max_mean_min.get("mean_acc")) is not None and ( duration := max_mean_min.get("duration") @@ -1739,12 +1745,12 @@ def statistic_during_period( _type_column_mapping = { - "last_reset": "last_reset_ts", - "max": "max", - "mean": "mean", - "min": "min", - "state": "state", - "sum": "sum", + "last_reset": ("last_reset_ts",), + "max": ("max",), + "mean": ("mean", "mean_weight"), + "min": ("min",), + "state": ("state",), + "sum": ("sum",), } @@ -1756,12 +1762,13 @@ def _generate_select_columns_for_types_stmt( track_on: list[str | None] = [ table.__tablename__, # type: ignore[attr-defined] ] - for key, column in _type_column_mapping.items(): - if key in types: - columns = columns.add_columns(getattr(table, column)) - track_on.append(column) - else: - track_on.append(None) + for key, type_columns in _type_column_mapping.items(): + for column in type_columns: + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) return lambda_stmt(lambda: columns, track_on=track_on) @@ -1944,6 +1951,12 @@ def _statistics_during_period_with_session( hass, session, start_time, units, _types, table, metadata, result ) + # filter out mean_weight as it is only needed to reduce statistics + # and not needed in the result + for stats_rows in result.values(): + for row in stats_rows: + row.pop("mean_weight", None) + # Return statistics combined with metadata return result @@ -2391,7 +2404,12 @@ def _sorted_statistics_to_dict( field_map["last_reset"] = field_map.pop("last_reset_ts") sum_idx = field_map["sum"] if "sum" in types else None sum_only = len(types) == 1 and sum_idx is not None - row_mapping = tuple((key, field_map[key]) for key in types if key in field_map) + row_mapping = tuple( + (column, field_map[column]) + for key in types + for column in ({key, *_type_column_mapping.get(key, ())}) + if column in field_map + ) # Append all statistic entries, and optionally do unit conversion table_duration_seconds = table.duration.total_seconds() for meta_id, db_rows in stats_by_meta_id.items(): diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cb80fa7d2ce..c321caa616d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -160,7 +160,7 @@ def _time_weighted_arithmetic_mean( def _time_weighted_circular_mean( fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime -) -> float: +) -> tuple[float, float]: """Calculate a time weighted circular mean. The circular mean is calculated by weighting the states by duration in seconds between @@ -623,7 +623,7 @@ def compile_statistics( # noqa: C901 valid_float_states, start, end ) case StatisticMeanType.CIRCULAR: - stat["mean"] = _time_weighted_circular_mean( + stat["mean"], stat["mean_weight"] = _time_weighted_circular_mean( valid_float_states, start, end ) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 962c0a0ef8f..43f185f939a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4508,23 +4508,19 @@ async def test_compile_statistics_hourly_daily_monthly_summary( duration += dur return total / duration - def _time_weighted_circular_mean(values: list[tuple[float, int]]): + def _weighted_circular_mean( + values: Iterable[tuple[float, float]], + ) -> tuple[float, float]: sin_sum = 0 cos_sum = 0 - for x, dur in values: - sin_sum += math.sin(x * DEG_TO_RAD) * dur - cos_sum += math.cos(x * DEG_TO_RAD) * dur + for x, weight in values: + sin_sum += math.sin(x * DEG_TO_RAD) * weight + cos_sum += math.cos(x * DEG_TO_RAD) * weight - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 - - def _circular_mean(values: list[float]) -> float: - sin_sum = 0 - cos_sum = 0 - for x in values: - sin_sum += math.sin(x * DEG_TO_RAD) - cos_sum += math.cos(x * DEG_TO_RAD) - - return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + return ( + (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360, + math.sqrt(sin_sum**2 + cos_sum**2), + ) def _min(seq, last_state): if last_state is None: @@ -4631,7 +4627,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( values = [(seq, durations[j]) for j, seq in enumerate(seq)] if (state := last_states["sensor.test5"]) is not None: values.append((state, 5)) - expected_means["sensor.test5"].append(_time_weighted_circular_mean(values)) + expected_means["sensor.test5"].append(_weighted_circular_mean(values)) last_states["sensor.test5"] = seq[-1] start += timedelta(minutes=5) @@ -4733,15 +4729,17 @@ async def test_compile_statistics_hourly_daily_monthly_summary( start = zero end = zero + timedelta(minutes=5) for i in range(24): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - "sensor.test5", + for entity_id, mean_extractor in ( + ("sensor.test1", lambda x: x), + ("sensor.test2", lambda x: x), + ("sensor.test3", lambda x: x), + ("sensor.test4", lambda x: x), + ("sensor.test5", lambda x: x[0]), ): expected_average = ( - expected_means[entity_id][i] if entity_id in expected_means else None + mean_extractor(expected_means[entity_id][i]) + if entity_id in expected_means + else None ) expected_minimum = ( expected_minima[entity_id][i] if entity_id in expected_minima else None @@ -4772,7 +4770,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( assert stats == expected_stats def verify_stats( - period: Literal["5minute", "day", "hour", "week", "month"], + period: Literal["hour", "day", "week", "month"], start: datetime, next_datetime: Callable[[datetime], datetime], ) -> None: @@ -4791,7 +4789,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( ("sensor.test2", mean), ("sensor.test3", mean), ("sensor.test4", mean), - ("sensor.test5", _circular_mean), + ("sensor.test5", lambda x: _weighted_circular_mean(x)[0]), ): expected_average = ( mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12]) From 69e241d2e652b6e15ba1cc4fe9dca878c4d7a61d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 4 Apr 2025 22:03:02 +0200 Subject: [PATCH 145/217] Add Docker host networking issue detection (#142259) * Add Docker host networking issue detection * Update homeassistant/components/network/strings.json Co-authored-by: Jan-Philipp Benecke * Process review comments --------- Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/network/__init__.py | 31 +++++++++++- homeassistant/components/network/strings.json | 6 +++ .../network/snapshots/test_init.ambr | 22 +++++++++ tests/components/network/test_init.py | 49 +++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/components/network/snapshots/test_init.ambr diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 200cce86997..14c7dc55cf0 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -4,12 +4,14 @@ from __future__ import annotations from ipaddress import IPv4Address, IPv6Address, ip_interface import logging +from pathlib import Path from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass +from homeassistant.util import package from . import util from .const import ( @@ -27,6 +29,19 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +def _check_docker_without_host_networking() -> bool: + """Check if we are not using host networking in Docker.""" + if not package.is_docker_env(): + # We are not in Docker, so we don't need to check for host networking + return True + + if Path("/proc/sys/net/ipv4/ip_forward").exists(): + # If we can read this file, we likely have host networking + return True + + return False + + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: """Get the network adapter configuration.""" @@ -166,5 +181,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_network(hass) + if not await hass.async_add_executor_job(_check_docker_without_host_networking): + docs_url = "https://docs.docker.com/network/network-tutorial-host/" + install_url = "https://www.home-assistant.io/installation/linux#install-home-assistant-container" + ir.async_create_issue( + hass, + DOMAIN, + "docker_host_network", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="docker_host_network", + learn_more_url=install_url, + translation_placeholders={"docs_url": docs_url, "install_url": install_url}, + ) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json index 6aca7343221..3e135fff60b 100644 --- a/homeassistant/components/network/strings.json +++ b/homeassistant/components/network/strings.json @@ -6,5 +6,11 @@ "ipv6_addresses": "IPv6 addresses", "announce_addresses": "Announce addresses" } + }, + "issues": { + "docker_host_network": { + "title": "Home Assistant is not using host networking", + "description": "Home Assistant is running in a container without host networking mode. This can cause networking issues with device discovery, multicast, broadcast, other network features, and incorrectly detecting its own URL and IP addresses, causing issues with media players and sending audio responses to voice assistants.\n\nIt is recommended to run Home Assistant with host networking by adding the `--network host` flag to your Docker run command or setting `network_mode: host` in your `docker-compose.yml` file.\n\nSee the [Docker documentation]({docs_url}) for more information about Docker host networking and refer to the [Home Assistant installation guide]({install_url}) for our recommended and supported setup." + } } } diff --git a/tests/components/network/snapshots/test_init.ambr b/tests/components/network/snapshots/test_init.ambr new file mode 100644 index 00000000000..268c8e0d44f --- /dev/null +++ b/tests/components/network/snapshots/test_init.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_repair_docker_host_network_without_host_networking[mock_socket0] + IssueRegistryItemSnapshot({ + 'active': True, + 'breaks_in_ha_version': None, + 'created': , + 'data': None, + 'dismissed_version': None, + 'domain': 'network', + 'is_fixable': False, + 'is_persistent': False, + 'issue_domain': None, + 'issue_id': 'docker_host_network', + 'learn_more_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + 'severity': , + 'translation_key': 'docker_host_network', + 'translation_placeholders': dict({ + 'docs_url': 'https://docs.docker.com/network/network-tutorial-host/', + 'install_url': 'https://www.home-assistant.io/installation/linux#install-home-assistant-container', + }), + }) +# --- diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index a2352e6af9e..372dba1772d 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -1,10 +1,13 @@ """Test the Network Configuration.""" +from __future__ import annotations + from ipaddress import IPv4Address from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import network from homeassistant.components.network.const import ( @@ -17,6 +20,7 @@ from homeassistant.components.network.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR @@ -801,3 +805,48 @@ async def test_websocket_network_url( "external": None, "cloud": None, } + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_not_docker( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when not in Docker.""" + with patch("homeassistant.util.package.is_docker_env", return_value=False): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_with_host_networking( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test repair is not created when in Docker with host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=True), + ): + assert await async_setup_component(hass, "network", {}) + + assert not issue_registry.async_get_issue(DOMAIN, "docker_host_network") + + +@pytest.mark.parametrize("mock_socket", [[]], indirect=True) +@pytest.mark.usefixtures("mock_socket") +async def test_repair_docker_host_network_without_host_networking( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test repair is created when in Docker without host networking.""" + with ( + patch("homeassistant.util.package.is_docker_env", return_value=True), + patch("homeassistant.components.network.Path.exists", return_value=False), + ): + assert await async_setup_component(hass, "network", {}) + + assert (issue := issue_registry.async_get_issue(DOMAIN, "docker_host_network")) + assert issue == snapshot From 8d95fb3b313a5158d4cf61f25d9c97e2b8a7d16a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 4 Apr 2025 16:17:52 -0400 Subject: [PATCH 146/217] Fix empty actions (#142292) * Apply fix * Add tests for alarm button cover lock * update light * add number tests * test select * add switch tests * test vacuum * update lock test --- .../template/alarm_control_panel.py | 3 +- homeassistant/components/template/button.py | 3 +- homeassistant/components/template/cover.py | 3 +- homeassistant/components/template/fan.py | 3 +- homeassistant/components/template/light.py | 8 +- homeassistant/components/template/lock.py | 3 +- homeassistant/components/template/select.py | 6 +- homeassistant/components/template/switch.py | 5 +- homeassistant/components/template/vacuum.py | 3 +- .../template/test_alarm_control_panel.py | 15 +++ tests/components/template/test_button.py | 39 ++++++ tests/components/template/test_cover.py | 49 +++++++ tests/components/template/test_light.py | 115 ++++++++++++++++ tests/components/template/test_lock.py | 51 ++++++- tests/components/template/test_number.py | 76 ++++++++++- tests/components/template/test_select.py | 77 ++++++++++- tests/components/template/test_switch.py | 46 +++++++ tests/components/template/test_vacuum.py | 127 +++++++++++++++++- 18 files changed, 613 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 40206a5ccbb..208077a4153 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -214,7 +214,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore ), (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 7a205446585..4ee8844d6e7 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -120,7 +120,8 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Initialize the button.""" super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None - if action := config.get(CONF_PRESS): + # Scripts can be an empty list, therefore we need to check for None + if (action := config.get(CONF_PRESS)) is not None: self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 7a8e347ee8f..7c9c0ea9d53 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -172,7 +172,8 @@ class CoverTemplate(TemplateEntity, CoverEntity): (POSITION_ACTION, CoverEntityFeature.SET_POSITION), (TILT_ACTION, TILT_FEATURES), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 6e0f9fe5e0c..f3bc26391a9 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -157,7 +157,8 @@ class TemplateFan(TemplateEntity, FanEntity): CONF_SET_OSCILLATING_ACTION, CONF_SET_DIRECTION_ACTION, ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._state: bool | None = False diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 1cc47c74aa0..c58709eba5e 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -296,7 +296,8 @@ class LightTemplate(TemplateEntity, LightEntity): self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._state = False @@ -323,7 +324,8 @@ class LightTemplate(TemplateEntity, LightEntity): (CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) color_modes.add(color_mode) self._supported_color_modes = filter_supported_color_modes(color_modes) @@ -333,7 +335,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - if self._action_scripts.get(CONF_EFFECT_ACTION): + if (self._action_scripts.get(CONF_EFFECT_ACTION)) is not None: self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index b19cadff26c..12a3e66cb5e 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -98,7 +98,8 @@ class TemplateLock(TemplateEntity, LockEntity): (CONF_UNLOCK, 0), (CONF_OPEN, LockEntityFeature.OPEN), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index eb60a3dbfe4..74d88ee96c4 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -141,7 +141,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - if select_option := config.get(CONF_SELECT_OPTION): + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) @@ -197,7 +198,8 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - if select_option := config.get(CONF_SELECT_OPTION): + # Scripts can be an empty list, therefore we need to check for None + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script( CONF_SELECT_OPTION, select_option, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index fb3aeb1e42a..1d18ea9d5ca 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -226,9 +226,10 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): assert name is not None self._template = config.get(CONF_STATE) - if on_action := config.get(CONF_TURN_ON): + # Scripts can be an empty list, therefore we need to check for None + if (on_action := config.get(CONF_TURN_ON)) is not None: self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) - if off_action := config.get(CONF_TURN_OFF): + if (off_action := config.get(CONF_TURN_OFF)) is not None: self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) self._state: bool | None = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index c4d41b52f31..1e18b06436a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -158,7 +158,8 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), ): - if action_config := config.get(action_id): + # Scripts can be an empty list, therefore we need to check for None + if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 4b259fabac2..2a99e00a9ce 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -82,6 +82,15 @@ OPTIMISTIC_TEMPLATE_ALARM_CONFIG = { "data": {"code": "{{ this.entity_id }}"}, }, } +EMPTY_ACTIONS = { + "arm_away": [], + "arm_home": [], + "arm_night": [], + "arm_vacation": [], + "arm_custom_bypass": [], + "disarm": [], + "trigger": [], +} TEMPLATE_ALARM_CONFIG = { @@ -173,6 +182,12 @@ async def test_setup_config_entry( "panels": {"test_template_panel": OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, } }, + { + "alarm_control_panel": { + "platform": "template", + "panels": {"test_template_panel": EMPTY_ACTIONS}, + } + }, ], ) @pytest.mark.usefixtures("start_ha") diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index b201385240c..31239dbaf92 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -93,6 +93,45 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: _verify(hass, STATE_UNKNOWN) +async def test_missing_emtpy_press_action_config( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": [], + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN) + + now = dt.datetime.now(dt.UTC) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_BUTTON}, + blocking=True, + ) + + _verify( + hass, + now.isoformat(), + ) + + async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" with assert_setup_component(0, "template"): diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index c49db59c2ee..668592e388b 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -9,6 +9,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( @@ -28,6 +29,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component from tests.common import assert_setup_component @@ -1123,3 +1125,50 @@ async def test_self_referencing_icon_with_no_template_is_not_a_loop( assert len(hass.states.async_all()) == 1 assert "Template loop detected" not in caplog.text + + +@pytest.mark.parametrize( + ("script", "supported_feature"), + [ + ("stop_cover", CoverEntityFeature.STOP), + ("set_cover_position", CoverEntityFeature.SET_POSITION), + ( + "set_cover_tilt_position", + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, + ), + ], +) +async def test_emtpy_action_config( + hass: HomeAssistant, script: str, supported_feature: CoverEntityFeature +) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, COVER_DOMAIN): + assert await async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + "open_cover": [], + "close_cover": [], + script: [], + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("cover.test_template_cover") + assert ( + state.attributes["supported_features"] + == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | supported_feature + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 1a739b4921e..c0aade84e0f 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -556,6 +556,42 @@ async def setup_single_action_light( ) +@pytest.fixture +async def setup_empty_action_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + action: str, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + "turn_on": [], + "turn_off": [], + action: [], + **extra_config, + }, + ) + + @pytest.fixture async def setup_light_with_effects( hass: HomeAssistant, @@ -2404,3 +2440,82 @@ async def test_nested_unique_id( entry = entity_registry.async_get("light.test_b") assert entry assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize(("count", "extra_config"), [(1, {})]) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ], +) +@pytest.mark.parametrize( + ("action", "color_mode"), + [ + ("set_level", ColorMode.BRIGHTNESS), + ("set_temperature", ColorMode.COLOR_TEMP), + ("set_hs", ColorMode.HS), + ("set_rgb", ColorMode.RGB), + ("set_rgbw", ColorMode.RGBW), + ("set_rgbww", ColorMode.RGBWW), + ], +) +async def test_empty_color_mode_action_config( + hass: HomeAssistant, + color_mode: ColorMode, + setup_empty_action_light, +) -> None: + """Test empty actions for color mode actions.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_color_modes"] == [color_mode] + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_ON + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_template_light"}, + blocking=True, + ) + + state = hass.states.get("light.test_template_light") + assert state.state == STATE_OFF + + +@pytest.mark.parametrize(("count"), [1]) +@pytest.mark.parametrize( + ("style", "extra_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "effect_list_template": "{{ ['a'] }}", + "effect_template": "{{ 'a' }}", + }, + ), + ( + ConfigurationStyle.MODERN, + { + "effect_list": "{{ ['a'] }}", + "effect": "{{ 'a' }}", + }, + ), + ], +) +@pytest.mark.parametrize("action", ["set_effect"]) +async def test_effect_with_empty_action( + hass: HomeAssistant, + setup_empty_action_light, +) -> None: + """Test empty set_effect action.""" + state = hass.states.get("light.test_template_light") + assert state.attributes["supported_features"] == LightEntityFeature.EFFECT diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index d9cb294c41f..50baa11b2d0 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -4,7 +4,7 @@ import pytest from homeassistant import setup from homeassistant.components import lock -from homeassistant.components.lock import LockState +from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -15,6 +15,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall +from tests.common import assert_setup_component + OPTIMISTIC_LOCK_CONFIG = { "platform": "template", "lock": { @@ -718,3 +720,50 @@ async def test_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all("lock")) == 1 + + +async def test_emtpy_action_config(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + with assert_setup_component(1, lock.DOMAIN): + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + lock.DOMAIN: { + "platform": "template", + "value_template": "{{ 0 == 1 }}", + "lock": [], + "unlock": [], + "open": [], + "name": "test_template_lock", + "optimistic": True, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.attributes["supported_features"] == LockEntityFeature.OPEN + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.UNLOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.test_template_lock"}, + ) + await hass.async_block_till_done() + + state = hass.states.get("lock.test_template_lock") + assert state.state == LockState.LOCKED diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index f73a943e752..5201541e2e0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -1,8 +1,12 @@ """The tests for the Template number platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import number, template from homeassistant.components.input_number import ( ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, DOMAIN as INPUT_NUMBER_DOMAIN, @@ -18,6 +22,7 @@ from homeassistant.components.number import ( ) from homeassistant.components.template import DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, CONF_UNIT_OF_MEASUREMENT, @@ -25,10 +30,14 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_NUMBER = "number.template_number" +_TEST_OBJECT_ID = "template_number" +_TEST_NUMBER = f"number.{_TEST_OBJECT_ID}" # Represent for number's value _VALUE_INPUT_NUMBER = "input_number.value" # Represent for number's minimum @@ -50,6 +59,38 @@ _VALUE_INPUT_NUMBER_CONFIG = { } +async def async_setup_modern_format( + hass: HomeAssistant, count: int, number_config: dict[str, Any] +) -> None: + """Do setup of number integration via new format.""" + config = {"template": {"number": number_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_number( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + number_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **number_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -565,3 +606,36 @@ async def test_device_id( template_entity = entity_registry.async_get("number.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "state": "{{ 1 }}", + "set_value": [], + "step": "{{ 1 }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 4}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 4 diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 59ab45aeb36..b2bc56af44a 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -1,8 +1,12 @@ """The tests for the Template select platform.""" +from typing import Any + +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import setup +from homeassistant.components import select, template from homeassistant.components.input_select import ( ATTR_OPTION as INPUT_SELECT_ATTR_OPTION, ATTR_OPTIONS as INPUT_SELECT_ATTR_OPTIONS, @@ -17,17 +21,53 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import MockConfigEntry, assert_setup_component, async_capture_events -_TEST_SELECT = "select.template_select" +_TEST_OBJECT_ID = "template_select" +_TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" +async def async_setup_modern_format( + hass: HomeAssistant, count: int, select_config: dict[str, Any] +) -> None: + """Do setup of select integration via new format.""" + config = {"template": {"select": select_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_select( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + select_config: dict[str, Any], +) -> None: + """Do setup of select integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": _TEST_OBJECT_ID, **select_config} + ) + + async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -527,3 +567,36 @@ async def test_device_id( template_entity = entity_registry.async_get("select.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "state": "{{ 'b' }}", + "select_option": [], + "options": "{{ ['a', 'b'] }}", + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "a"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "a" diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index d8877851efe..43db93ac146 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -981,3 +981,49 @@ async def test_device_id( template_entity = entity_registry.async_get("switch.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "switch_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + TEST_OBJECT_ID: { + "turn_on": [], + "turn_off": [], + }, + }, + ), + ( + ConfigurationStyle.MODERN, + { + "name": TEST_OBJECT_ID, + "turn_on": [], + "turn_off": [], + }, + ), + ], +) +async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: + """Test configuration with empty script.""" + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6053a2bd9ec..cc5bc9b39e3 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1,18 +1,29 @@ """The tests for the Template vacuum platform.""" +from typing import Any + import pytest from homeassistant import setup -from homeassistant.components.vacuum import ATTR_BATTERY_LEVEL, VacuumActivity +from homeassistant.components import vacuum +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + VacuumActivity, + VacuumEntityFeature, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.vacuum import common -_TEST_VACUUM = "vacuum.test_vacuum" +_TEST_OBJECT_ID = "test_vacuum" +_TEST_VACUUM = f"vacuum.{_TEST_OBJECT_ID}" _STATE_INPUT_SELECT = "input_select.state" _SPOT_CLEANING_INPUT_BOOLEAN = "input_boolean.spot_cleaning" _LOCATING_INPUT_BOOLEAN = "input_boolean.locating" @@ -20,6 +31,50 @@ _FAN_SPEED_INPUT_SELECT = "input_select.fan_speed" _BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of number integration via new format.""" + config = {"vacuum": {"platform": "template", "vacuums": vacuum_config}} + + with assert_setup_component(count, vacuum.DOMAIN): + assert await async_setup_component( + hass, + vacuum.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_vacuum( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, vacuum_config) + + +@pytest.fixture +async def setup_test_vacuum_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + vacuum_config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of number integration.""" + config = {_TEST_OBJECT_ID: {**vacuum_config, **extra_config}} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + + @pytest.mark.parametrize(("count", "domain"), [(1, "vacuum")]) @pytest.mark.parametrize( ("parm1", "parm2", "config"), @@ -697,3 +752,71 @@ async def _register_components(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "vacuum_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "start": [], + }, + ), + ], +) +@pytest.mark.parametrize( + ("extra_config", "supported_features"), + [ + ( + { + "pause": [], + }, + VacuumEntityFeature.PAUSE, + ), + ( + { + "stop": [], + }, + VacuumEntityFeature.STOP, + ), + ( + { + "return_to_base": [], + }, + VacuumEntityFeature.RETURN_HOME, + ), + ( + { + "clean_spot": [], + }, + VacuumEntityFeature.CLEAN_SPOT, + ), + ( + { + "locate": [], + }, + VacuumEntityFeature.LOCATE, + ), + ( + { + "set_fan_speed": [], + }, + VacuumEntityFeature.FAN_SPEED, + ), + ], +) +async def test_empty_action_config( + hass: HomeAssistant, + supported_features: VacuumEntityFeature, + setup_test_vacuum_with_extra_config, +) -> None: + """Test configuration with empty script.""" + await common.async_start(hass, _TEST_VACUUM) + await hass.async_block_till_done() + + state = hass.states.get(_TEST_VACUUM) + assert state.attributes["supported_features"] == ( + VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features + ) From 0abe57edaa701af82a214f314e29d518427ab1e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 10:28:55 -1000 Subject: [PATCH 147/217] Avoid checking if debug logging is enabled on every WebSocket message (#142258) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/websocket_api/http.py | 23 +++++++++---- tests/components/websocket_api/test_http.py | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index ebca497193b..4250da149ad 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -14,7 +14,7 @@ from aiohttp import WSMsgType, web from aiohttp.http_websocket import WebSocketWriter from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -73,6 +73,7 @@ class WebSocketHandler: "_authenticated", "_closing", "_connection", + "_debug", "_handle_task", "_hass", "_logger", @@ -107,6 +108,12 @@ class WebSocketHandler: self._message_queue: deque[bytes] = deque() self._ready_future: asyncio.Future[int] | None = None self._release_ready_queue_size: int = 0 + self._async_logging_changed() + + @callback + def _async_logging_changed(self, event: Event | None = None) -> None: + """Handle logging change.""" + self._debug = self._logger.isEnabledFor(logging.DEBUG) def __repr__(self) -> str: """Return the representation.""" @@ -137,7 +144,6 @@ class WebSocketHandler: logger = self._logger wsock = self._wsock loop = self._loop - is_debug_log_enabled = partial(logger.isEnabledFor, logging.DEBUG) debug = logger.debug can_coalesce = connection.can_coalesce ready_message_count = len(message_queue) @@ -157,14 +163,14 @@ class WebSocketHandler: if not can_coalesce or ready_message_count == 1: message = message_queue.popleft() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, message) await send_bytes_text(message) continue coalesced_messages = b"".join((b"[", b",".join(message_queue), b"]")) message_queue.clear() - if is_debug_log_enabled(): + if self._debug: debug("%s: Sending %s", self.description, coalesced_messages) await send_bytes_text(coalesced_messages) except asyncio.CancelledError: @@ -325,6 +331,9 @@ class WebSocketHandler: unsub_stop = hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop ) + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) writer = wsock._writer # noqa: SLF001 if TYPE_CHECKING: @@ -354,6 +363,7 @@ class WebSocketHandler: "%s: Unexpected error inside websocket API", self.description ) finally: + cancel_logging_listener() unsub_stop() self._cancel_peak_checker() @@ -401,7 +411,7 @@ class WebSocketHandler: except ValueError as err: raise Disconnect("Received invalid JSON during auth phase") from err - if self._logger.isEnabledFor(logging.DEBUG): + if self._debug: self._logger.debug("%s: Received %s", self.description, auth_msg_data) connection = await auth.async_handle(auth_msg_data) # As the webserver is now started before the start @@ -463,7 +473,6 @@ class WebSocketHandler: wsock = self._wsock async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary - _debug_enabled = partial(self._logger.isEnabledFor, logging.DEBUG) # Command phase while not wsock.closed: @@ -496,7 +505,7 @@ class WebSocketHandler: except ValueError as ex: raise Disconnect("Received invalid JSON.") from ex - if _debug_enabled(): + if self._debug: self._logger.debug( "%s: Received %s", self.description, command_msg_data ) diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 370aab1067a..075f5fa9c0a 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -16,6 +16,7 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -523,3 +524,36 @@ async def test_binary_message( assert "Received binary message for non-existing handler 0" in caplog.text assert "Received binary message for non-existing handler 3" in caplog.text assert "Received binary message for non-existing handler 10" in caplog.text + + +async def test_enable_disable_debug_logging( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enabling and disabling debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.websocket_api": "DEBUG"}, + blocking=True, + ) + await hass.async_block_till_done() + await websocket_client.send_json({"id": 1, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 1 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":1,"type":"pong"}\'' in caplog.text + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.websocket_api": "WARNING"}, + blocking=True, + ) + await hass.async_block_till_done() + await websocket_client.send_json({"id": 2, "type": "ping"}) + msg = await websocket_client.receive_json() + assert msg["id"] == 2 + assert msg["type"] == "pong" + assert 'Sending b\'{"id":2,"type":"pong"}\'' not in caplog.text From 52724c5c226e119d0a97bb0d44a67562baa902be Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 4 Apr 2025 23:20:23 +0200 Subject: [PATCH 148/217] Bump DSMR parser to 1.4.3 (#142303) --- homeassistant/components/dsmr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 561f06d1bbe..f9e78ac616f 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.4.2"] + "requirements": ["dsmr-parser==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22287c8c577..f5f6c0582d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -802,7 +802,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e914b91275..4e2cec122ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.4.2 +dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 From 39ebc103df0afcee52606bb14594b3b01b7868c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 11:20:36 -1000 Subject: [PATCH 149/217] Bump pydantic to 2.11.2 (#142302) changelog: https://github.com/pydantic/pydantic/compare/v2.11.1...v2.11.2 --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fbf10ce142a..c714efb8a9c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -130,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.1 +pydantic==2.11.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index f7b04f0a6bd..b53b1fd8840 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a7 pre-commit==4.0.0 -pydantic==2.11.1 +pydantic==2.11.2 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 11698f01e45..f155068c7e7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.1 +pydantic==2.11.2 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From f9cd0f37f75feec01e9702e96f8b06a3b2c117c1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 4 Apr 2025 23:22:05 +0200 Subject: [PATCH 150/217] Add common states "Normal", "Very high" and "Very low" (#142167) --- homeassistant/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 763d50e79d7..14190ba008d 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -133,6 +133,7 @@ "low": "Low", "medium": "Medium", "no": "No", + "normal": "Normal", "not_home": "Away", "off": "Off", "on": "On", @@ -142,6 +143,8 @@ "standby": "Standby", "stopped": "Stopped", "unlocked": "Unlocked", + "very_high": "Very high", + "very_low": "Very low", "yes": "Yes" }, "time": { From 1e55d4b613cfe6310bbf04ad7f552e64d9f8aff6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Apr 2025 23:26:43 +0200 Subject: [PATCH 151/217] Restore "Promote after dependencies in bootstrap" (#142001) Revert "Revert "Promote after dependencies in bootstrap" (#141584)" This reverts commit de1e06c39bce99f55ea36175e29cc1d76bc35836. --- homeassistant/bootstrap.py | 28 +++++++++++----------------- tests/test_bootstrap.py | 18 ++++++++++-------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 02a3b8c8fcc..962c7871028 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -859,8 +859,14 @@ async def _async_set_up_integrations( integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - all_domains = set(all_integrations) - domains = set(integrations) + # Detect all cycles + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, all_integrations.values(), set(all_integrations) + ) + ) + all_domains = set(integrations_after_dependencies) + domains = set(integrations) & all_domains _LOGGER.info( "Domains to be set up: %s | %s", @@ -868,6 +874,8 @@ async def _async_set_up_integrations( all_domains - domains, ) + async_set_domains_to_be_loaded(hass, all_domains) + # Initialize recorder if "recorder" in all_domains: recorder.async_initialize_recorder(hass) @@ -900,24 +908,12 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered = { dep for domain in stage_domains - for dep in all_integrations[domain].all_dependencies + for dep in integrations_after_dependencies[domain] if dep not in stage_domains } stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_all_domains = stage_domains | stage_dep_domains - stage_all_integrations = { - domain: all_integrations[domain] for domain in stage_all_domains - } - # Detect all cycles - stage_integrations_after_dependencies = ( - await loader.resolve_integrations_after_dependencies( - hass, stage_all_integrations.values(), stage_all_domains - ) - ) - stage_all_domains = set(stage_integrations_after_dependencies) - stage_domains &= stage_all_domains - stage_dep_domains &= stage_all_domains _LOGGER.info( "Setting up stage %s: %s | %s\nDependencies: %s | %s", @@ -928,8 +924,6 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered - stage_dep_domains, ) - async_set_domains_to_be_loaded(hass, stage_all_domains) - if timeout is None: await _async_setup_multi_components(hass, stage_all_domains, config) continue diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1fb87ac5ef6..ca75dc51c56 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -252,8 +252,8 @@ async def test_setup_after_deps_all_present(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: - """Test after_dependencies are ignored in stage 1.""" +async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: + """Test after_dependencies are promoted in stage 1.""" # This test relies on this assert "cloud" in bootstrap.STAGE_1_INTEGRATIONS order = [] @@ -295,7 +295,7 @@ async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: assert "normal_integration" in hass.config.components assert "cloud" in hass.config.components - assert order == ["cloud", "an_after_dep", "normal_integration"] + assert order == ["an_after_dep", "normal_integration", "cloud"] @pytest.mark.parametrize("load_registries", [False]) @@ -304,7 +304,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( ) -> None: """Ensure we preload manifests for after deps even if they are not setup. - Its important that we preload the after dep manifests even if they are not setup + It's important that we preload the after dep manifests even if they are not setup since we will always have to check their requirements since any integration that lists an after dep may import it and we have to ensure requirements are up to date before the after dep can be imported. @@ -371,7 +371,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( assert "an_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep_of_after_dep" not in hass.config.components - assert order == ["cloud", "normal_integration"] + assert order == ["normal_integration", "cloud"] assert loader.async_get_loaded_integration(hass, "an_after_dep") is not None assert ( loader.async_get_loaded_integration(hass, "an_after_dep_of_after_dep") @@ -456,9 +456,9 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert order == [ "http", + "an_after_dep", "frontend", "recorder", - "an_after_dep", "normal_integration", ] @@ -1577,8 +1577,10 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> assert not isinstance(integrations_or_excs, Exception) integrations[domain] = integration - integrations_all_dependencies = await loader.resolve_integrations_dependencies( - hass, integrations.values() + integrations_all_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations.values(), ignore_exceptions=True + ) ) all_integrations = integrations.copy() all_integrations.update( From 1d10c81ff3b581dab5fbbd7434c7cefa0e4b41d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 11:27:05 -1000 Subject: [PATCH 152/217] Add coverage to flux_led to ensure a user flow can replace an ignored entry (#142103) * Ensure a flux_led user flow can replace an ignored entry Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for flux_led * works as-is was a problem with core.config_entries --- tests/components/flux_led/test_config_flow.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index f486d27244e..14ac4dd23ab 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -356,6 +356,60 @@ async def test_manual_working_discovery(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" +async def test_user_flow_can_replace_ignored(hass: HomeAssistant) -> None: + """Test a user flow can replace an ignored entry.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + title=DEFAULT_ENTRY_TITLE, + source=config_entries.SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + 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 not result["errors"] + + # Cannot connect (timeout) + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + # Success + with ( + _patch_discovery(), + _patch_wifibulb(), + patch(f"{MODULE}.async_setup", return_value=True), + patch(f"{MODULE}.async_setup_entry", return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == DEFAULT_ENTRY_TITLE + assert result4["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + } + + async def test_manual_no_discovery_data(hass: HomeAssistant) -> None: """Test manually setup without discovery data.""" result = await hass.config_entries.flow.async_init( From 414fe53261b4fd5484cb46b1cd842bcafe766990 Mon Sep 17 00:00:00 2001 From: Emily Love Watson Date: Fri, 4 Apr 2025 20:23:22 -0500 Subject: [PATCH 153/217] Bump pykulersky dependency (#142311) --- homeassistant/components/kulersky/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json index e0d9ec4fe36..49c4d4c1847 100644 --- a/homeassistant/components/kulersky/manifest.json +++ b/homeassistant/components/kulersky/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kulersky", "iot_class": "local_polling", "loggers": ["bleak", "pykulersky"], - "requirements": ["pykulersky==0.5.2"] + "requirements": ["pykulersky==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index f5f6c0582d0..02258e6261d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,7 +2077,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.kwb pykwb==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e2cec122ae..cd91423e032 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1695,7 +1695,7 @@ pykoplenti==1.3.0 pykrakenapi==0.1.8 # homeassistant.components.kulersky -pykulersky==0.5.2 +pykulersky==0.5.8 # homeassistant.components.lamarzocco pylamarzocco==1.4.9 From 31c660557de310fd4e9a8d0ec428a807b90894fb Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 4 Apr 2025 22:58:46 -0400 Subject: [PATCH 154/217] Update Roborock map more consistently on state change (#142228) * update map more consistently on state change * Makecoordinator keep track of last_updated_state --- homeassistant/components/roborock/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index cc0bee1cd5f..4e59a092e0a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -153,6 +153,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ImageConfig(scale=MAP_SCALE), [], ) + self.last_update_state: str | None = None @cached_property def dock_device_info(self) -> DeviceInfo: @@ -291,7 +292,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def _async_update_data(self) -> DeviceProp: """Update data via library.""" - previous_state = self.roborock_device_info.props.status.state_name try: # Update device props and standard api information await self._update_device_prop() @@ -308,7 +308,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): and (dt_util.utcnow() - self.maps[self.current_map].last_updated) > IMAGE_CACHE_INTERVAL ) - or previous_state != new_status.state_name + or self.last_update_state != new_status.state_name ): try: await self.update_map() @@ -330,6 +330,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL else: self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL + self.last_update_state = self.roborock_device_info.props.status.state_name return self.roborock_device_info.props def _set_current_map(self) -> None: From be32968ed4272e24ef4cef5f5a4da0c1b0fc0934 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 11:01:47 +0200 Subject: [PATCH 155/217] Use common states for sensor levels in `overkiz` (#142325) --- homeassistant/components/overkiz/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 05b5eac4b21..da6c01219f1 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -120,10 +120,10 @@ "battery": { "state": { "full": "Full", - "low": "Low", - "normal": "Normal", - "medium": "Medium", - "verylow": "Very low", + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "medium": "[%key:common::state::medium%]", + "verylow": "[%key:common::state::very_low%]", "good": "Good", "critical": "Critical" } @@ -131,9 +131,9 @@ "discrete_rssi_level": { "state": { "good": "Good", - "low": "Low", - "normal": "Normal", - "verylow": "Very low" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]", + "verylow": "[%key:common::state::very_low%]" } }, "priority_lock_originator": { From e235a04dae53fb56f5929822c948e87e948950cb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 11:02:46 +0200 Subject: [PATCH 156/217] Use common states for sensor levels in `nam` (#142323) --- homeassistant/components/nam/strings.json | 60 +++++++++++------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 000dfe74112..b02eecaa41e 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -96,20 +96,20 @@ "pmsx003_caqi_level": { "name": "PMSx003 common air quality index level", "state": { - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -129,20 +129,20 @@ "sds011_caqi_level": { "name": "SDS011 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } @@ -165,20 +165,20 @@ "sps30_caqi_level": { "name": "SPS30 common air quality index level", "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" }, "state_attributes": { "options": { "state": { - "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", - "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", - "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", - "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", - "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } } } From d07378e87b25a93cabbac4706e87d8764dbb425d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 5 Apr 2025 02:03:20 -0700 Subject: [PATCH 157/217] Bump opower to 0.10.0 (#142321) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 2da4511c0aa..e691d01257a 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.9.0"] + "requirements": ["opower==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 02258e6261d..fd6ce683fd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1607,7 +1607,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.9.0 +opower==0.10.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd91423e032..39ba516906d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1341,7 +1341,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.9.0 +opower==0.10.0 # homeassistant.components.oralb oralb-ble==0.17.6 From d7e36513b52d6e482361394f57c1aba42d310456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 4 Apr 2025 23:05:01 -1000 Subject: [PATCH 158/217] Bump inkbird-ble to 0.10.1 (#142314) * Bump inkbird-ble to 0.10.0 changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.9.0...v0.10.0 * Apply suggestions from code review --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index aaa9c4b3473..ea980babf7e 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -40,5 +40,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.9.0"] + "requirements": ["inkbird-ble==0.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fd6ce683fd3..63f7a299746 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1232,7 +1232,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.10.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39ba516906d..7adba92bf6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1044,7 +1044,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.9.0 +inkbird-ble==0.10.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 4ab31e2d4e2eb81b2a43c6fd38ae47cf1d78d683 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 11:05:43 +0200 Subject: [PATCH 159/217] Use common states for sensor levels in `tomorrowio` (#142324) --- .../components/tomorrowio/strings.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index 03a8a169920..c3f52155d29 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -115,33 +115,33 @@ "name": "Tree pollen index", "state": { "none": "None", - "very_low": "Very low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very high" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "weed_pollen_index": { "name": "Weed pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "grass_pollen_index": { "name": "Grass pollen index", "state": { "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", - "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", - "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", - "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", - "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", - "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + "very_low": "[%key:common::state::very_low%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]" } }, "fire_index": { @@ -153,10 +153,10 @@ "uv_radiation_health_concern": { "name": "UV radiation health concern", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "high": "High", - "very_high": "Very high", + "high": "[%key:common::state::high%]", + "very_high": "[%key:common::state::very_high%]", "extreme": "Extreme" } } From 1ab8deff3d7c1e825c6537840e3b5cd582f423e4 Mon Sep 17 00:00:00 2001 From: Tomek Wasilczyk <1107414+twasilczyk@users.noreply.github.com> Date: Sat, 5 Apr 2025 03:12:34 -0700 Subject: [PATCH 160/217] Add missing test_all requirements (#142036) Fix homeassistant_hardware handling and add missing test_all requirements --- requirements_test_all.txt | 6 ++++++ script/gen_requirements_all.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7adba92bf6b..f1e2e8590e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -949,6 +949,9 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 @@ -2396,6 +2399,9 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.2.0 +# homeassistant.components.homeassistant_hardware +universal-silabs-flasher==0.0.30 + # homeassistant.components.upb upb-lib==0.6.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f155068c7e7..acc87ec2731 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -266,7 +266,8 @@ def has_tests(module: str) -> bool: Test if exists: tests/components/hue/__init__.py """ path = ( - Path(module.replace(".", "/").replace("homeassistant", "tests")) / "__init__.py" + Path(module.replace(".", "/").replace("homeassistant", "tests", 1)) + / "__init__.py" ) return path.exists() From 904265bca7111bf174179049c4d5c0b8fbbbd3f8 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 5 Apr 2025 13:56:52 +0200 Subject: [PATCH 161/217] Add reauth flow to Pterodactyl (#142285) * Add reauth flow * Add common function to validate connection in config flow * Fix remaining review findings --- .../components/pterodactyl/config_flow.py | 68 +++++++++++++++--- .../components/pterodactyl/coordinator.py | 9 ++- .../components/pterodactyl/quality_scale.yaml | 2 +- .../components/pterodactyl/strings.json | 13 +++- .../pterodactyl/test_config_flow.py | 71 ++++++++++++++++++- 5 files changed, 145 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py index e78ae776123..db03c89f95e 100644 --- a/homeassistant/components/pterodactyl/config_flow.py +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -29,36 +30,81 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Pterodactyl.""" VERSION = 1 + async def async_validate_connection(self, url: str, api_key: str) -> dict[str, str]: + """Validate the connection to the Pterodactyl server.""" + errors: dict[str, str] = {} + api = PterodactylAPI(self.hass, url, api_key) + + try: + await api.async_init() + except PterodactylAuthorizationError: + errors["base"] = "invalid_auth" + except PterodactylConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception occurred during config flow") + errors["base"] = "unknown" + + return errors + 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 = URL(user_input[CONF_URL]).human_repr() api_key = user_input[CONF_API_KEY] self._async_abort_entries_match({CONF_URL: url}) - api = PterodactylAPI(self.hass, url, api_key) + errors = await self.async_validate_connection(url, api_key) - try: - await api.async_init() - except PterodactylAuthorizationError: - errors["base"] = "invalid_auth" - except PterodactylConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception occurred during config flow") - errors["base"] = "unknown" - else: + if not errors: return self.async_create_entry(title=url, data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform re-authentication on an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that re-authentication is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + url = reauth_entry.data[CONF_URL] + api_key = user_input[CONF_API_KEY] + + errors = await self.async_validate_connection(url, api_key) + + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py index c8456ce9e55..6d644e96e4c 100644 --- a/homeassistant/components/pterodactyl/coordinator.py +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -8,6 +8,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( @@ -55,12 +56,16 @@ class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): try: await self.api.async_init() - except (PterodactylAuthorizationError, PterodactylConnectionError) as error: + except PterodactylConnectionError as error: raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error async def _async_update_data(self) -> dict[str, PterodactylData]: """Get updated data from the Pterodactyl server.""" try: return await self.api.async_get_data() - except (PterodactylAuthorizationError, PterodactylConnectionError) as error: + except PterodactylConnectionError as error: raise UpdateFailed(error) from error + except PterodactylAuthorizationError as error: + raise ConfigEntryAuthFailed(error) from error diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml index dae3b9fa11a..80ebb3fc7e3 100644 --- a/homeassistant/components/pterodactyl/quality_scale.yaml +++ b/homeassistant/components/pterodactyl/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: done comment: Handled by coordinator. parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json index fe2b7730e1b..3d01700f189 100644 --- a/homeassistant/components/pterodactyl/strings.json +++ b/homeassistant/components/pterodactyl/strings.json @@ -10,6 +10,16 @@ "url": "The URL of your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.", "api_key": "The account API key for accessing your Pterodactyl server." } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please update your account API key.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::pterodactyl::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -18,7 +28,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 3cb7f1c19d4..88247085083 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -8,10 +8,11 @@ from requests.models import Response from homeassistant.components.pterodactyl.const import DOMAIN from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import TEST_URL, TEST_USER_INPUT +from .conftest import TEST_API_KEY, TEST_URL, TEST_USER_INPUT from tests.common import MockConfigEntry @@ -90,11 +91,10 @@ async def test_recovery_after_error( assert result["data"] == TEST_USER_INPUT -@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.usefixtures("mock_setup_entry", "mock_pterodactyl") async def test_service_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pterodactyl: PterodactylClient, ) -> None: """Test config flow abort if the Pterodactyl server is already configured.""" mock_config_entry.add_to_hass(hass) @@ -105,3 +105,68 @@ async def test_service_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_reauth_full_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth config flow success.""" + 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={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("exception_type", "expected_error"), + [ + (PterodactylApiError, "cannot_connect"), + (BadRequestError, "cannot_connect"), + (Exception, "unknown"), + (HTTPError(response=mock_response()), "invalid_auth"), + ], +) +async def test_reauth_recovery_after_error( + hass: HomeAssistant, + exception_type: Exception, + expected_error: str, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test recovery after an error during re-authentication.""" + 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" + + mock_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert mock_config_entry.data[CONF_API_KEY] == TEST_API_KEY From 9692d637ca40b42a9f40ca4769a3e9fc387e6aae Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Sun, 6 Apr 2025 01:26:35 +1300 Subject: [PATCH 162/217] Add reauth flow to bosch_alarm (#142251) * add reauth flow * fix tests * move not happy flow to its own test * reference existing strings * Update test_config_flow.py --- .../components/bosch_alarm/__init__.py | 11 ++- .../components/bosch_alarm/config_flow.py | 53 ++++++++++++++ .../components/bosch_alarm/quality_scale.yaml | 2 +- .../components/bosch_alarm/strings.json | 23 +++++- .../bosch_alarm/test_config_flow.py | 73 +++++++++++++++++++ tests/components/bosch_alarm/test_init.py | 16 +++- 6 files changed, 172 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index bc7fee46f60..ddd736b47c0 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -9,7 +9,7 @@ from bosch_alarm_mode2 import Panel from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN @@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) - await panel.connect() except (PermissionError, ValueError) as err: await panel.disconnect() - raise ConfigEntryNotReady from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: await panel.disconnect() - raise ConfigEntryNotReady("Connection failed") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err entry.runtime_data = panel diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index e48f2a11944..4b1e3e511fc 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging import ssl from typing import Any @@ -163,3 +164,55 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + self._data = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + self._data.update(user_input) + try: + (_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + 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=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 467760fb863..75c331ede40 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -40,7 +40,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: todo - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index f4846021b55..3123c1697f3 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -22,6 +22,18 @@ "installer_code": "The installer code from your panel", "user_code": "The user code from your panel" } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]" + }, + "data_description": { + "password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]", + "installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]", + "user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]" + } } }, "error": { @@ -30,7 +42,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to panel." + }, + "authentication_failed": { + "message": "Incorrect credentials for panel." } } } diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py index 066b3008821..4a1c9dad3ea 100644 --- a/tests/components/bosch_alarm/test_config_flow.py +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -13,6 +13,8 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry @@ -210,3 +212,74 @@ async def test_entry_already_configured_serial( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + # Now check it works when there are no errors + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (OSError(), "cannot_connect"), + (PermissionError(), "invalid_auth"), + (Exception(), "unknown"), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test reauth flow.""" + await setup_integration(hass, mock_config_entry) + result = await mock_config_entry.start_reauth_flow(hass) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + + assert result["step_id"] == "reauth_confirm" + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == message + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=config_flow_data, + ) + assert result["reason"] == "reauth_successful" + compare = {**mock_config_entry.data, **config_flow_data} + assert compare == mock_config_entry.data diff --git a/tests/components/bosch_alarm/test_init.py b/tests/components/bosch_alarm/test_init.py index 0497a91eadf..13e938bd711 100644 --- a/tests/components/bosch_alarm/test_init.py +++ b/tests/components/bosch_alarm/test_init.py @@ -20,12 +20,26 @@ def disable_platform_only(): @pytest.mark.parametrize("model", ["solution_3000"]) -@pytest.mark.parametrize("exception", [PermissionError(), TimeoutError()]) +@pytest.mark.parametrize("exception", [PermissionError()]) async def test_incorrect_auth( hass: HomeAssistant, mock_panel: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [TimeoutError()]) +async def test_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, ) -> None: """Test errors with incorrect auth.""" mock_panel.connect.side_effect = exception From f29019960694946aa5d88a99cfa3d2232a862029 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 5 Apr 2025 18:07:06 +0200 Subject: [PATCH 163/217] Add error details in remote calendar flow (#141753) * Add error details in remote calendar flow * no args * adjust * json * Apply suggestions * remove description placeholder --- homeassistant/components/remote_calendar/config_flow.py | 5 ++++- homeassistant/components/remote_calendar/strings.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index cc9f45e2767..802a7eb7cea 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -69,7 +69,10 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ) except CalendarParseError as err: errors["base"] = "invalid_ics_file" - _LOGGER.debug("Invalid .ics file: %s", err) + _LOGGER.error("Error reading the calendar information: %s", err.message) + _LOGGER.debug( + "Additional calendar error detail: %s", str(err.detailed_error) + ) else: return self.async_create_entry( title=user_input[CONF_CALENDAR_NAME], data=user_input diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index fff2d4abbb3..ef7f20d4699 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -20,7 +20,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "forbidden": "The server understood the request but refuses to authorize it.", - "invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]" + "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "exceptions": { From 9f4b2ad05a92c5257cbc7faff1eeb3b2e6fb028b Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 5 Apr 2025 20:13:51 +0200 Subject: [PATCH 164/217] Bump xiaomi-ble to 0.35.0 (#142350) bump xiaomi-ble --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 26dd82c73bc..d7156246d38 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.33.0"] + "requirements": ["xiaomi-ble==0.35.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63f7a299746..ebbfbe6d964 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3091,7 +3091,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.35.0 # homeassistant.components.knx xknx==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1e2e8590e3..95759e4e12f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2496,7 +2496,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.33.0 +xiaomi-ble==0.35.0 # homeassistant.components.knx xknx==3.6.0 From 236f33537b4a4bfb76a221979422c56c0e6d94fe Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 20:20:27 +0200 Subject: [PATCH 165/217] Use common states for "Low" and "Normal" in `dsmr` (#142354) Use common state for "Low" and "Normal" in `dsmr` --- homeassistant/components/dsmr/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 871dd382f2b..e95e9ae870a 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -51,8 +51,8 @@ "electricity_active_tariff": { "name": "Active tariff", "state": { - "low": "Low", - "normal": "Normal" + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" } }, "electricity_delivered_tariff_1": { From 913d3d4ac68e8d56298885520b79ccca3a07b60f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 20:21:03 +0200 Subject: [PATCH 166/217] Use common states for sensor levels in `openuv` (#142346) Replace states "Low", "High" and "Very high" with (new) common states. --- homeassistant/components/openuv/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 9349d2cc116..f3b9aa686d5 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -54,10 +54,10 @@ "name": "Current UV level", "state": { "extreme": "Extreme", - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } }, "max_uv_index": { From 8121d147a6a1331492b80dba88c79ea47301d657 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sat, 5 Apr 2025 20:48:16 +0200 Subject: [PATCH 167/217] Add SensorDeviceClass and unit for LCN CO2 sensor. (#142320) Add SesnorDeviceClass and unit for LCN CO2 sensor. --- homeassistant/components/lcn/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 7783df8679a..0c78ea6637a 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE, @@ -49,6 +50,7 @@ DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: SensorDeviceClass.SPEED, pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE, pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, + pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2, } UNIT_OF_MEASUREMENT_MAPPING = { @@ -60,6 +62,7 @@ UNIT_OF_MEASUREMENT_MAPPING = { pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND, pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, + pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION, } From 051a5030471a8f888871dd7fef9e60ee647f732b Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 5 Apr 2025 11:49:38 -0700 Subject: [PATCH 168/217] Add a description for the enable_google_search_tool option in Google AI (#142322) * Add a description for the enable_google_search_tool option in Google AI * Use quotes --- .../components/google_generative_ai_conversation/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index cd7af711d3a..2697f30eda0 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -40,7 +40,8 @@ "enable_google_search_tool": "Enable Google Search tool" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "enable_google_search_tool": "Only works with \"No control\" in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." } } }, From a29ba51bdb49b60f7af2d8a002a09109aa09769f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Apr 2025 21:11:46 +0200 Subject: [PATCH 169/217] Use common states for sensor levels in `accuweather` (#142345) Replace states "Low", "High" and "Very high" with (new) common states. --- .../components/accuweather/strings.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index e1a71c5e1a5..e81ef782d98 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -72,10 +72,10 @@ "level": { "name": "Level", "state": { - "high": "High", - "low": "Low", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "Moderate", - "very_high": "Very high" + "very_high": "[%key:common::state::very_high%]" } } } @@ -89,10 +89,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -123,10 +123,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -167,10 +167,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -181,10 +181,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } @@ -195,10 +195,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", - "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" + "very_high": "[%key:common::state::very_high%]" } } } From 52143155e78a2cf769fa02ee415aae760da70757 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 5 Apr 2025 21:12:13 +0200 Subject: [PATCH 170/217] Record quality scale for IMGW-PIB (#141380) * Record quality scale for IMGW-PIB * Update quality scale * Add the scale to the manifest * Typo * Suggested --- .../components/imgw_pib/manifest.json | 1 + .../components/imgw_pib/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/imgw_pib/quality_scale.yaml diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 3d8b34055fd..e2d6e2bf584 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", + "quality_scale": "silver", "requirements": ["imgw_pib==1.0.10"] } diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml new file mode 100644 index 00000000000..6634c915255 --- /dev/null +++ b/homeassistant/components/imgw_pib/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + 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 services. + 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: The integration does not register services. + 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: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: This is a service, which doesn't integrate with any devices. + docs-supported-functions: todo + docs-troubleshooting: + status: exempt + comment: No known issues that could be resolved by the user. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration has a fixed single service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: Only parameter that could be changed station_id would force a new config entry. + repair-issues: + status: exempt + comment: This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: This integration has a fixed single service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index fdcbe16f092..49da98f5872 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -513,7 +513,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "iglo", "ign_sismologia", "ihc", - "imgw_pib", "improv_ble", "influxdb", "inkbird", @@ -1573,7 +1572,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ign_sismologia", "ihc", "imap", - "imgw_pib", "improv_ble", "influxdb", "inkbird", From 660cbc136f40f49e414d2f8d80c18dfbbe7a2a65 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 5 Apr 2025 15:05:01 -0500 Subject: [PATCH 171/217] Add move queue item HEOS entity service (#142301) --- homeassistant/components/heos/const.py | 2 + homeassistant/components/heos/icons.json | 3 ++ homeassistant/components/heos/media_player.py | 7 +++ homeassistant/components/heos/services.py | 15 +++++++ homeassistant/components/heos/services.yaml | 20 +++++++++ homeassistant/components/heos/strings.json | 14 ++++++ tests/components/heos/__init__.py | 1 + tests/components/heos/test_media_player.py | 44 +++++++++++++++++++ 8 files changed, 106 insertions(+) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index b83da128c91..d49fc17aa53 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -2,6 +2,7 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" +ATTR_DESTINATION_POSITION = "destination_position" ATTR_QUEUE_IDS = "queue_ids" DOMAIN = "heos" ENTRY_TITLE = "HEOS System" @@ -9,6 +10,7 @@ SERVICE_GET_QUEUE = "get_queue" SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_UP = "group_volume_up" +SERVICE_MOVE_QUEUE_ITEM = "move_queue_item" SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index c11b499fc0b..b03f15a4b0f 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -6,6 +6,9 @@ "remove_from_queue": { "service": "mdi:playlist-remove" }, + "move_queue_item": { + "service": "mdi:playlist-edit" + }, "group_volume_set": { "service": "mdi:volume-medium" }, diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 65314439c18..294da492e31 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -479,6 +479,13 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Remove items from the queue.""" await self._player.remove_from_queue(queue_ids) + @catch_action_error("move queue item") + async def async_move_queue_item( + self, queue_ids: list[int], destination_position: int + ) -> None: + """Move items in the queue.""" + await self._player.move_queue_item(queue_ids, destination_position) + @property def available(self) -> bool: """Return True if the device is available.""" diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index fe8c887691c..86c6f6d0533 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -19,6 +19,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.typing import VolDictType, VolSchemaType from .const import ( + ATTR_DESTINATION_POSITION, ATTR_PASSWORD, ATTR_QUEUE_IDS, ATTR_USERNAME, @@ -27,6 +28,7 @@ from .const import ( SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, SERVICE_REMOVE_FROM_QUEUE, SERVICE_SIGN_IN, SERVICE_SIGN_OUT, @@ -87,6 +89,16 @@ REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = { GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = { vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float } +MOVE_QEUEUE_ITEM_SCHEMA: Final[VolDictType] = { + vol.Required(ATTR_QUEUE_IDS): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=1, max=1000))], + vol.Unique(), + ), + vol.Required(ATTR_DESTINATION_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=1, max=1000) + ), +} MEDIA_PLAYER_ENTITY_SERVICES: Final = ( # Player queue services @@ -96,6 +108,9 @@ MEDIA_PLAYER_ENTITY_SERVICES: Final = ( EntityServiceDescription( SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA ), + EntityServiceDescription( + SERVICE_MOVE_QUEUE_ITEM, "async_move_queue_item", MOVE_QEUEUE_ITEM_SCHEMA + ), # Group volume services EntityServiceDescription( SERVICE_GROUP_VOLUME_SET, diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index fd74b2f90c4..333a15940bc 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -17,6 +17,26 @@ remove_from_queue: multiple: true type: number +move_queue_item: + target: + entity: + integration: heos + domain: media_player + fields: + queue_ids: + required: true + selector: + text: + multiple: true + type: number + destination_position: + required: true + selector: + number: + min: 1 + max: 1000 + step: 1 + group_volume_set: target: entity: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 982d15a06fa..c99d73a70d7 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -100,6 +100,20 @@ } } }, + "move_queue_item": { + "name": "Move queue item", + "description": "Move one or more items within the play queue.", + "fields": { + "queue_ids": { + "name": "Queue IDs", + "description": "The IDs (indexes) of the items in the queue to move." + }, + "destination_position": { + "name": "Destination position", + "description": "The position index in the queue to move the items to." + } + } + }, "group_volume_down": { "name": "Turn down group volume", "description": "Turns down the group volume." diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index cdf93c202f0..edc128f2f78 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -39,6 +39,7 @@ class MockHeos(Heos): self.player_clear_queue: AsyncMock = AsyncMock() self.player_get_queue: AsyncMock = AsyncMock() self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_move_queue_item: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock() self.player_play_queue: AsyncMock = AsyncMock() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 085a42337b3..30d17f4a8ca 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -27,12 +27,14 @@ from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.heos.const import ( + ATTR_DESTINATION_POSITION, ATTR_QUEUE_IDS, DOMAIN, SERVICE_GET_QUEUE, SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, + SERVICE_MOVE_QUEUE_ITEM, SERVICE_REMOVE_FROM_QUEUE, ) from homeassistant.components.media_player import ( @@ -1784,3 +1786,45 @@ async def test_remove_from_queue( blocking=True, ) controller.player_remove_from_queue.assert_called_once_with(1, [1, 2]) + + +async def test_move_queue_item_queue( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the move queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) + controller.player_move_queue_item.assert_called_once_with(1, [1, 2], 10) + + +async def test_move_queue_item_queue_error_raises( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test move queue raises error when failed.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_move_queue_item.side_effect = HeosError("error") + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to move queue item: error"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MOVE_QUEUE_ITEM, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_QUEUE_IDS: [1, "2"], + ATTR_DESTINATION_POSITION: 10, + }, + blocking=True, + ) From ae0f27c42f863844ec77e1749c1b2d0ef5dac03a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 5 Apr 2025 22:38:11 +0200 Subject: [PATCH 172/217] Limit mqtt info logging for discovery of new components (#142344) * Limit mqtt info logging for discovery of new component * Keep in bail out, when debug logging is not enabled --- homeassistant/components/mqtt/discovery.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a14240ce008..a527e712615 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -154,18 +154,14 @@ def get_origin_support_url(discovery_payload: MQTTDiscoveryPayload) -> str | Non @callback def async_log_discovery_origin_info( - message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO + message: str, discovery_payload: MQTTDiscoveryPayload ) -> None: """Log information about the discovery and origin.""" - # We only log origin info once per device discovery - if not _LOGGER.isEnabledFor(level): - # bail out early if logging is disabled + if not _LOGGER.isEnabledFor(logging.DEBUG): + # bail out early if debug logging is disabled return - _LOGGER.log( - level, - "%s%s", - message, - get_origin_log_string(discovery_payload, include_url=True), + _LOGGER.debug( + "%s%s", message, get_origin_log_string(discovery_payload, include_url=True) ) @@ -562,7 +558,7 @@ async def async_start( # noqa: C901 elif already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" - async_log_discovery_origin_info(message, payload, logging.DEBUG) + async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(*discovery_hash), payload ) From 6da37691ff641937551f6bd7d7075e2c28fcbe1f Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sat, 5 Apr 2025 22:51:22 +0200 Subject: [PATCH 173/217] Improve enphase_envoy diagnostics error handling to retain collected data (#142255) Improve enphase_envoy Diagnostics error handling to retain collected data --- .../components/enphase_envoy/diagnostics.py | 28 ++++----- .../snapshots/test_diagnostics.ambr | 61 ++++++++++++++++++- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index d5b3880cf24..80eed76574f 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: ] for end_point in end_points: - response = await envoy.request(end_point) - fixture_data[end_point] = response.text.replace("\n", "").replace( - serial, CLEAN_TEXT - ) - fixture_data[f"{end_point}_log"] = json_dumps( - { - "headers": dict(response.headers.items()), - "code": response.status_code, - } - ) + try: + response = await envoy.request(end_point) + fixture_data[end_point] = response.text.replace("\n", "").replace( + serial, CLEAN_TEXT + ) + fixture_data[f"{end_point}_log"] = json_dumps( + { + "headers": dict(response.headers.items()), + "code": response.status_code, + } + ) + except EnvoyError as err: + fixture_data[f"{end_point}_log"] = {"Error": repr(err)} return fixture_data @@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics( fixture_data: dict[str, Any] = {} if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False): - try: - fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) - except EnvoyError as err: - fixture_data["Error"] = repr(err) + fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial) diagnostic_data: dict[str, Any] = { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 152cf803258..69ef4ecaead 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -1373,7 +1373,66 @@ ]), }), 'fixtures': dict({ - 'Error': "EnvoyError('Test')", + '/admin/lib/tariff_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production/inverters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/api/v1/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/info_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/dry_contacts_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/generator_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/inventory_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/power_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/secctrl_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ensemble/status_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters/readings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/meters_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/sc/pvlimit_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/dry_contact_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_config_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/gen_schedule_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/ivp/ss/pel_settings_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json?details=1_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production.json_log': dict({ + 'Error': "EnvoyError('Test')", + }), + '/production_log': dict({ + 'Error': "EnvoyError('Test')", + }), }), 'raw_data': dict({ 'varies_by': 'firmware_version', From 33cbebc7279e7092976017e15dc8c9383d68de49 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 5 Apr 2025 22:51:43 +0200 Subject: [PATCH 174/217] Add some Xiaomi BLE sensor translations (#142109) --- homeassistant/components/xiaomi_ble/sensor.py | 31 ++++++++++++++----- .../components/xiaomi_ble/strings.json | 8 +++++ tests/components/xiaomi_ble/test_sensor.py | 27 ++++++++-------- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 01f15ff09b8..57dfaead232 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -9,9 +9,11 @@ from xiaomi_ble.parser import ExtendedSensorDeviceClass from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( + EntityDescription, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -78,6 +80,7 @@ SENSOR_DESCRIPTIONS = { icon="mdi:omega", native_unit_of_measurement=Units.OHM, state_class=SensorStateClass.MEASUREMENT, + translation_key="impedance", ), # Mass sensor (kg) (DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( @@ -93,6 +96,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + translation_key="weight_non_stabilized", ), (DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription( key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}", @@ -180,18 +184,20 @@ def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, ) -> PassiveBluetoothDataUpdate[float | None]: """Convert a sensor update to a bluetooth data update.""" + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = { + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class + } + return PassiveBluetoothDataUpdate( devices={ device_id: sensor_device_info_to_hass_device_info(device_info) for device_id, device_info in sensor_update.devices.items() }, - entity_descriptions={ - device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ - (description.device_class, description.native_unit_of_measurement) - ] - for device_key, description in sensor_update.entity_descriptions.items() - if description.device_class - }, + entity_descriptions=entity_descriptions, entity_data={ device_key_to_bluetooth_entity_key(device_key): cast( float | None, sensor_values.native_value @@ -201,6 +207,17 @@ def sensor_update_to_bluetooth_data_update( entity_names={ device_key_to_bluetooth_entity_key(device_key): sensor_values.name for device_key, sensor_values in sensor_update.entity_values.items() + # Add names where the entity description has neither a translation_key nor + # a device_class + if ( + description := entity_descriptions.get( + device_key_to_bluetooth_entity_key(device_key) + ) + ) + is None + or ( + description.translation_key is None and description.device_class is None + ) }, ) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 4ea4a47c61e..cdee3fc3838 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -227,6 +227,14 @@ } } } + }, + "sensor": { + "impedance": { + "name": "Impedance" + }, + "weight_non_stabilized": { + "name": "Weight non stabilized" + } } } } diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 11a20a62d02..f5625d4e74d 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -694,21 +694,21 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Mass Non Stabilized" + == "Mi Smart Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "86.55" - assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Mass" + assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Weight" assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -736,22 +736,23 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_body_composition_scale_b5dc_mass_non_stabilized" + "sensor.mi_body_composition_scale_b5dc_weight_non_stabilized" ) mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Mass Non Stabilized" + == "Mi Body Composition Scale (B5DC) Weight non stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" - mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_mass") + mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_weight") mass_sensor_attr = mass_sensor.attributes assert mass_sensor.state == "85.15" assert ( - mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Body Composition Scale (B5DC) Mass" + mass_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Weight" ) assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -845,7 +846,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -866,7 +867,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -896,7 +897,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) assert mass_non_stabilized_sensor.state == "86.55" @@ -917,7 +918,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time @@ -930,7 +931,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() mass_non_stabilized_sensor = hass.states.get( - "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + "sensor.mi_smart_scale_b5dc_weight_non_stabilized" ) # Sleepy devices should keep their state over time and restore it From cd7d7cd35c8b1c3dae1fdbca79c533e6eaa4608a Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 5 Apr 2025 14:02:46 -0700 Subject: [PATCH 175/217] Add reconfiguration flow to NUT (#142127) * Add reconfiguration flow * Check host/port/alias without comparing strings * Replace repeat strings with references --- homeassistant/components/nut/config_flow.py | 138 +++- homeassistant/components/nut/strings.json | 25 +- tests/components/nut/test_config_flow.py | 778 +++++++++++++++++++- tests/components/nut/util.py | 27 +- 4 files changed, 948 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 5996c1c0087..a69d898ff6c 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Mapping import logging +from types import MappingProxyType from typing import Any from aionut import NUTError, NUTLoginError @@ -27,16 +28,26 @@ from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) -AUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} +REAUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + +PASSWORD_NOT_CHANGED = "__**password_not_changed**__" -def _base_schema(nut_config: dict[str, Any]) -> vol.Schema: +def _base_schema( + nut_config: dict[str, Any] | MappingProxyType[str, Any], + use_password_not_changed: bool = False, +) -> vol.Schema: """Generate base schema.""" base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_PASSWORD, + default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + ): str, } - base_schema.update(AUTH_SCHEMA) + return vol.Schema(base_schema) @@ -66,6 +77,26 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"ups_list": nut_data.ups_list, "available_resources": status} +def _check_host_port_alias_match( + first: Mapping[str, Any], second: Mapping[str, Any] +) -> bool: + """Check if first and second have the same host, port and alias.""" + + if first[CONF_HOST] != second[CONF_HOST] or first[CONF_PORT] != second[CONF_PORT]: + return False + + first_alias = first.get(CONF_ALIAS) + second_alias = second.get(CONF_ALIAS) + if (first_alias is None and second_alias is None) or ( + first_alias is not None + and second_alias is not None + and first_alias == second_alias + ): + return True + + return False + + def _format_host_port_alias(user_input: Mapping[str, Any]) -> str: """Format a host, port, and alias so it can be used for comparison or display.""" host = user_input[CONF_HOST] @@ -137,7 +168,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_ups( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the picking the ups.""" + """Handle selecting the NUT device alias.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} nut_config = self.nut_config @@ -163,6 +194,99 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + nut_config.update(user_input) + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + + if not errors: + if len(info["ups_list"]) > 1: + self.ups_list = info["ups_list"] + return await self.async_step_reconfigure_ups() + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_base_schema( + reconfigure_entry.data, + use_password_not_changed=True, + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_reconfigure_ups( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting the NUT device alias.""" + + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + nut_config = self.nut_config + + if user_input is not None: + self.nut_config.update(user_input) + + if not _check_host_port_alias_match( + reconfigure_entry.data, + nut_config, + ) and (self._host_port_alias_already_configured(nut_config)): + return self.async_abort(reason="already_configured") + + info, errors, placeholders = await self._async_validate_or_error(nut_config) + if not errors: + if unique_id := _unique_id_from_status(info["available_resources"]): + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + + if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED: + nut_config.pop(CONF_PASSWORD) + + new_title = _format_host_port_alias(nut_config) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + unique_id=unique_id, + title=new_title, + data_updates=nut_config, + ) + + return self.async_show_form( + step_id="reconfigure_ups", + data_schema=_ups_schema(self.ups_list or {}), + errors=errors, + description_placeholders=placeholders, + ) + def _host_port_alias_already_configured(self, user_input: dict[str, Any]) -> bool: """See if we already have a nut entry matching user input configured.""" existing_host_port_aliases = { @@ -204,6 +328,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth input.""" + errors: dict[str, str] = {} existing_entry = self.reauth_entry assert existing_entry @@ -212,6 +337,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: existing_data[CONF_HOST], CONF_PORT: existing_data[CONF_PORT], } + if user_input is not None: new_config = { **existing_data, @@ -229,8 +355,8 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders.update(placeholders) return self.async_show_form( - description_placeholders=description_placeholders, step_id="reauth_confirm", - data_schema=vol.Schema(AUTH_SCHEMA), + data_schema=vol.Schema(REAUTH_SCHEMA), errors=errors, + description_placeholders=description_placeholders, ) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index fe06bef3903..1e6cee786d3 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -28,6 +28,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reconfigure": { + "description": "[%key:component::nut::config::step::user::description%]", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::nut::config::step::user::data_description::host%]", + "port": "[%key:component::nut::config::step::user::data_description::port%]", + "username": "[%key:component::nut::config::step::user::data_description::username%]", + "password": "[%key:component::nut::config::step::user::data_description::password%]" + } + }, + "reconfigure_ups": { + "title": "[%key:component::nut::config::step::ups::title%]", + "data": { + "alias": "[%key:component::nut::config::step::ups::data::alias%]" + } } }, "error": { @@ -38,7 +59,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_ups_found": "There are no UPS devices available on the NUT server.", - "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%]", + "unique_id_mismatch": "The device's manufacturer, model and serial number identifier does not match the previous identifier." } }, "device_automation": { diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 6237ad341b4..c0e7f9ffeff 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant import config_entries, setup +from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED from homeassistant.components.nut.const import DOMAIN from homeassistant.const import ( CONF_ALIAS, @@ -83,8 +84,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_one_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_user_one_alias(hass: HomeAssistant) -> None: + """Test we can configure a device with one alias.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -128,8 +129,8 @@ async def test_form_user_one_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None: + """Test we can configure device with multiple aliases.""" await setup.async_setup_component(hass, "persistent_notification", {}) config_entry = MockConfigEntry( @@ -194,7 +195,7 @@ async def test_form_user_multiple_ups(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None: +async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> None: """Test we can setup a new one when there is an ignored one.""" ignored_entry = MockConfigEntry( domain=DOMAIN, data={}, source=config_entries.SOURCE_IGNORE @@ -244,8 +245,8 @@ async def test_form_user_one_ups_with_ignored_entry(hass: HomeAssistant) -> None assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_no_upses_found(hass: HomeAssistant) -> None: - """Test we abort when the NUT server has not UPSes.""" +async def test_form_no_aliases_found(hass: HomeAssistant) -> None: + """Test we abort when the NUT server has no aliases.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -561,8 +562,8 @@ async def test_abort_duplicate_unique_ids(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" -async def test_abort_multiple_ups_duplicate_unique_ids(hass: HomeAssistant) -> None: - """Test we abort on multiple devices if unique_id is already setup.""" +async def test_abort_multiple_aliases_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test we abort on multiple aliases if unique_id is already setup.""" list_vars = { "device.mfr": "Some manufacturer", @@ -670,3 +671,762 @@ async def test_abort_if_already_setup_alias(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "already_configured" + + +async def test_reconfigure_one_alias_successful(hass: HomeAssistant) -> None: + """Test reconfigure one alias successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + + +async def test_reconfigure_one_alias_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_password_nochange(hass: HomeAssistant) -> None: + """Test reconfigure one alias when there is no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_vars={"battery.voltage": "voltage"}, + list_ups={"ups1": "UPS 1"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_already_configured(hass: HomeAssistant) -> None: + """Test reconfigure when config changed to an existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: int(entry.data[CONF_PORT]), + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + +async def test_reconfigure_one_alias_unique_id_change(hass: HomeAssistant) -> None: + """Test reconfigure when the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + }, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_one_alias_duplicate_unique_ids(hass: HomeAssistant) -> None: + """Test reconfigure that results in a duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + username="test-username", + password="test-password", + list_ups={"ups2": "UPS 2"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={"ups2": "UPS 2"}, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_successful(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases is successful.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-new-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_nochange(hass: HomeAssistant) -> None: + """Test reconfigure with multiple aliases and no change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups1"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + +async def test_reconfigure_multiple_aliases_password_nochange( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases when no password change.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2.2.2.2", + CONF_PORT: 456, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: PASSWORD_NOT_CHANGED, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: "ups2"}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + assert entry.data[CONF_HOST] == "2.2.2.2" + assert entry.data[CONF_PORT] == 456 + assert entry.data[CONF_USERNAME] == "test-new-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_already_configured( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases changed to existing host/port/alias.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={"battery.voltage": "voltage"}, + ) + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={"battery.voltage": "voltage"}, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == "1.1.1.1" + assert entry.data[CONF_PORT] == 123 + assert entry.data[CONF_USERNAME] == "test-username" + assert entry.data[CONF_PASSWORD] == "test-password" + assert entry.data[CONF_ALIAS] == "ups1" + + assert entry2.data[CONF_HOST] == "2.2.2.2" + assert entry2.data[CONF_PORT] == 456 + assert entry2.data[CONF_USERNAME] == "test-username" + assert entry2.data[CONF_PASSWORD] == "test-password" + assert entry2.data[CONF_ALIAS] == "ups2" + + +async def test_reconfigure_multiple_aliases_unique_id_change( + hass: HomeAssistant, +) -> None: + """Test reconfigure with multiple aliases and the unique ID is changed.""" + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars={"battery.voltage": "voltage"}, + ) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" + + +async def test_reconfigure_multiple_aliases_duplicate_unique_ids( + hass: HomeAssistant, +) -> None: + """Test reconfigure multi aliases that results in duplicate unique ID.""" + + list_vars = { + "device.mfr": "Some manufacturer", + "device.model": "Some model", + "device.serial": "0000-1", + } + + entry = await async_init_integration( + hass, + host="1.1.1.1", + port=123, + alias="ups1", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1", "ups2": "UPS 2"}, + list_vars=list_vars, + ) + + entry2 = await async_init_integration( + hass, + host="2.2.2.2", + port=456, + alias="ups2", + username="test-username", + password="test-password", + list_ups={"ups1": "UPS 1"}, + list_vars={ + "device.mfr": "Another manufacturer", + "device.model": "Another model", + "device.serial": "0000-2", + }, + ) + + result = await entry2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pynut = _get_mock_nutclient( + list_ups={ + "ups1": "UPS 1", + "ups2": "UPS 2", + }, + list_vars=list_vars, + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "3.3.3.3", + CONF_PORT: 789, + CONF_USERNAME: "test-new-username", + CONF_PASSWORD: "test-new-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure_ups" + + with ( + patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ), + patch( + "homeassistant.components.nut.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ALIAS: entry.data[CONF_ALIAS]}, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "unique_id_mismatch" diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index 07c073f0286..889fdc327af 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -1,10 +1,17 @@ """Tests for the nut integration.""" import json +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_ALIAS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -35,8 +42,11 @@ def _get_mock_nutclient( async def async_init_integration( hass: HomeAssistant, ups_fixture: str | None = None, + host: str = "mock", + port: str = "mock", username: str = "mock", password: str = "mock", + alias: str | None = None, list_ups: dict[str, str] | None = None, list_vars: dict[str, str] | None = None, list_commands_return_value: dict[str, str] | None = None, @@ -65,15 +75,24 @@ async def async_init_integration( "homeassistant.components.nut.AIONUTClient", return_value=mock_pynut, ): + extra_config_entry_data: dict[str, Any] = {} + + if alias is not None: + extra_config_entry_data = { + CONF_ALIAS: alias, + } + entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "mock", + CONF_HOST: host, CONF_PASSWORD: password, - CONF_PORT: "mock", + CONF_PORT: port, CONF_USERNAME: username, - }, + } + | extra_config_entry_data, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) From bd8c723e08777abccf44b123b20098d928f399d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Apr 2025 11:07:21 -1000 Subject: [PATCH 176/217] Bump flux_led to 1.2.0 (#142362) changelog: https://github.com/lightinglibs/flux_led/compare/1.1.3...1.2.0 --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index fcb16c9742b..2c5e1b3839e 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -53,5 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "requirements": ["flux-led==1.1.3"] + "requirements": ["flux-led==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ebbfbe6d964..5aa70b17115 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -944,7 +944,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95759e4e12f..b17e98256c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -804,7 +804,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.3 +flux-led==1.2.0 # homeassistant.components.homekit # homeassistant.components.recorder From 0a7b4d18dcebf6dc24698de9eab580dcc3ed0ad8 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 5 Apr 2025 19:45:00 -0400 Subject: [PATCH 177/217] Check that the current roboorck map exists before updating it. (#142341) * Check that the current map exists * Add a few extra checks * Update coordinator.py Co-authored-by: Allen Porter * fixlint --------- Co-authored-by: Allen Porter --- .../components/roborock/coordinator.py | 18 +++++++++++------- homeassistant/components/roborock/sensor.py | 10 ++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 4e59a092e0a..2439a4f904a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -226,7 +226,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Update the currently selected map.""" # The current map was set in the props update, so these can be done without # worry of applying them to the wrong map. - if self.current_map is None: + if self.current_map is None or self.current_map not in self.maps: # This exists as a safeguard/ to keep mypy happy. return try: @@ -302,13 +302,17 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL # since the last map update, you can update the map. new_status = self.roborock_device_info.props.status - if self.current_map is not None and ( - ( - new_status.in_cleaning - and (dt_util.utcnow() - self.maps[self.current_map].last_updated) - > IMAGE_CACHE_INTERVAL + if ( + self.current_map is not None + and (current_map := self.maps.get(self.current_map)) + and ( + ( + new_status.in_cleaning + and (dt_util.utcnow() - current_map.last_updated) + > IMAGE_CACHE_INTERVAL + ) + or self.last_update_state != new_status.state_name ) - or self.last_update_state != new_status.state_name ): try: await self.update_map() diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 33ecaf74d4f..a007d6fa457 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -381,7 +381,10 @@ class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): @property def options(self) -> list[str]: """Return the currently valid rooms.""" - if self.coordinator.current_map is not None: + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): return list( self.coordinator.maps[self.coordinator.current_map].rooms.values() ) @@ -390,7 +393,10 @@ class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): @property def native_value(self) -> str | None: """Return the value reported by the sensor.""" - if self.coordinator.current_map is not None: + if ( + self.coordinator.current_map is not None + and self.coordinator.current_map in self.coordinator.maps + ): return self.coordinator.maps[self.coordinator.current_map].current_room return None From dcef86a30d7c201c725ad314550ddfcef11b3b15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Apr 2025 14:22:23 -1000 Subject: [PATCH 178/217] Add DHCP discovery support to Bond (#142372) * Add DHCP discovery support to Bond * fixes * unique ids are always upper * raise_on_progress=False for user * Update tests/components/bond/test_config_flow.py Co-authored-by: Joost Lekkerkerker * assert unique id --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bond/config_flow.py | 34 +++- homeassistant/components/bond/manifest.json | 10 ++ homeassistant/generated/dhcp.py | 10 ++ tests/components/bond/test_config_flow.py | 157 +++++++++++++++++++ 4 files changed, 206 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 38abd63186a..ffa0098840c 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered[CONF_ACCESS_TOKEN] = token try: - _, hub_name = await _validate_input(self.hass, self._discovered) + bond_id, hub_name = await _validate_input(self.hass, self._discovered) except InputValidationError: return + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._discovered[CONF_NAME] = hub_name + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by dhcp discovery.""" + host = discovery_info.ip + bond_id = discovery_info.hostname.partition("-")[2].upper() + await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: @@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) + return await self.async_step_any_discovery(bond_id, host) + + async def async_step_any_discovery( + self, bond_id: str, host: str + ) -> ConfigFlowResult: + """Handle a flow initialized by discovery.""" for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue updates = {CONF_HOST: host} - if entry.state == ConfigEntryState.SETUP_ERROR and ( + if entry.state is ConfigEntryState.SETUP_ERROR and ( token := await async_get_token(self.hass, host) ): updates[CONF_ACCESS_TOKEN] = token @@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._discovered[CONF_HOST], } try: - _, hub_name = await _validate_input(self.hass, data) + bond_id, hub_name = await _validate_input(self.hass, data) except InputValidationError as error: errors["base"] = error.base else: + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._discovered[CONF_HOST]} + ) return self.async_create_entry( title=hub_name, data=data, @@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN): except InputValidationError as error: errors["base"] = error.base else: - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(bond_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) return self.async_create_entry(title=hub_name, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 1d4c110f4fd..704b9934970 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,6 +3,16 @@ "name": "Bond", "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "config_flow": true, + "dhcp": [ + { + "hostname": "bond-*", + "macaddress": "3C6A2C1*" + }, + { + "hostname": "bond-*", + "macaddress": "F44E38*" + } + ], "documentation": "https://www.home-assistant.io/integrations/bond", "iot_class": "local_push", "loggers": ["bond_async"], diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8ee1ea270f3..9a8fd349a8b 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -84,6 +84,16 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "blink*", "macaddress": "20A171*", }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "3C6A2C1*", + }, + { + "domain": "bond", + "hostname": "bond-*", + "macaddress": "F44E38*", + }, { "domain": "broadlink", "registered_devices": True, diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 73aece4af6b..e5139b253aa 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -15,6 +15,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .common import ( @@ -63,6 +65,59 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_form_can_create_when_already_discovered( + hass: HomeAssistant, +) -> None: + """Test we get the user initiated form can create when already discovered.""" + + with patch_bond_version(), patch_bond_token(): + zc_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="mock_hostname", + name="ZXXX12345.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + assert zc_result["type"] is FlowResultType.FORM + assert zc_result["errors"] == {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "ZXXX12345"}), + patch_bond_device_ids(return_value=["f6776c11", "f6776c12"]), + patch_bond_bridge(), + patch_bond_device_properties(), + patch_bond_device(), + patch_bond_device_state(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "some host", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "ZXXX12345" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: """Test setup a smart by bond fan.""" @@ -97,6 +152,7 @@ async def test_user_form_with_non_bridge(hass: HomeAssistant) -> None: CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", } + assert result2["result"].unique_id == "KXXX12345" assert len(mock_setup_entry.mock_calls) == 1 @@ -253,6 +309,107 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test DHCP discovery.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: + """Test DHCP discovery for an already existing entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="KVPRBDJ45842", + ) + entry.add_to_hass(hass) + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_token(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ45842".lower(), + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: + """Test DHCP discovery with the name cut off.""" + + with patch_bond_version(), patch_bond_token(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="127.0.0.1", + hostname="Bond-KVPRBDJ", + macaddress=format_mac("3c:6a:2c:1c:8c:80"), + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch_bond_version(return_value={"bondid": "KVPRBDJ45842"}), + patch_bond_bridge(), + patch_bond_device_ids(), + _patch_async_setup_entry() as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCESS_TOKEN: "test-token"}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "bond-name" + assert result2["data"] == { + CONF_HOST: "127.0.0.1", + CONF_ACCESS_TOKEN: "test-token", + } + assert result2["result"].unique_id == "KVPRBDJ45842" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: """Test we get the discovery form and we handle the token being unavailable.""" From 55de21477c537ffd1e0cb29c2765442129fdae94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Apr 2025 17:35:19 -1000 Subject: [PATCH 179/217] Bump yarl to 1.19.0 (#142379) --- 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 c714efb8a9c..af75218bf7e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -74,7 +74,7 @@ voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.18.3 +yarl==1.19.0 zeroconf==0.146.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 8d81bf7ff03..7c35d1d2f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", - "yarl==1.18.3", + "yarl==1.19.0", "webrtc-models==0.3.0", "zeroconf==0.146.0", ] diff --git a/requirements.txt b/requirements.txt index f5e475bebce..b07a8710e5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,6 +58,6 @@ uv==0.6.10 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 -yarl==1.18.3 +yarl==1.19.0 webrtc-models==0.3.0 zeroconf==0.146.0 From c93b4cf61aabf9a60b2ff9e6e53bf7ea74e7ad6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 6 Apr 2025 09:23:45 +0300 Subject: [PATCH 180/217] Upgrade url-normalize to 2.2.0 (#142365) * https://github.com/niksite/url-normalize/releases/tag/2.0.0 * https://github.com/niksite/url-normalize/releases/tag/2.0.1 * https://github.com/niksite/url-normalize/releases/tag/2.1.0 * https://github.com/niksite/url-normalize/releases/tag/2.2.0 --- homeassistant/components/huawei_lte/manifest.json | 2 +- homeassistant/components/syncthru/manifest.json | 2 +- homeassistant/components/zwave_me/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 6720d6718ef..ce5316553ed 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -9,7 +9,7 @@ "requirements": [ "huawei-lte-api==1.10.0", "stringcase==1.2.0", - "url-normalize==1.4.3" + "url-normalize==2.2.0" ], "ssdp": [ { diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json index 461ce9bfd3a..11c688eb9af 100644 --- a/homeassistant/components/syncthru/manifest.json +++ b/homeassistant/components/syncthru/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/syncthru", "iot_class": "local_polling", "loggers": ["pysyncthru"], - "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"], + "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Printer:1", diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index d5c5a69cb96..43a39de29c5 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave-me-ws==0.4.3", "url-normalize==1.4.3"], + "requirements": ["zwave-me-ws==0.4.3", "url-normalize==2.2.0"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 5aa70b17115..c138a83924a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.0 # homeassistant.components.uvc uvcclient==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b17e98256c3..df9e228f367 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2411,7 +2411,7 @@ upcloud-api==2.6.0 # homeassistant.components.huawei_lte # homeassistant.components.syncthru # homeassistant.components.zwave_me -url-normalize==1.4.3 +url-normalize==2.2.0 # homeassistant.components.uvc uvcclient==0.12.1 From 62845fe4a7edc077098aa5a12ec35107383e2e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 6 Apr 2025 10:01:26 +0200 Subject: [PATCH 181/217] Update aioairzone to v1.0.0 (#142385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airzone/snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ tests/components/airzone/util.py | 10 ++++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 95ed9d200f4..1b636de0a47 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.9"] + "requirements": ["aioairzone==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c138a83924a..a51b5a132ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.4.4 aioairzone-cloud==0.6.11 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df9e228f367..6e8aa17b5fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.4.4 aioairzone-cloud==0.6.11 # homeassistant.components.airzone -aioairzone==0.9.9 +aioairzone==1.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index b4976c07e1b..09dea8c354c 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -44,9 +44,11 @@ }), dict({ 'air_demand': 1, + 'battery': 99, 'coldStage': 1, 'coldStages': 1, 'coldangle': 2, + 'coverage': 72, 'errors': list([ ]), 'floor_demand': 1, @@ -73,9 +75,11 @@ }), dict({ 'air_demand': 0, + 'battery': 35, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 60, 'errors': list([ ]), 'floor_demand': 0, @@ -100,9 +104,11 @@ }), dict({ 'air_demand': 0, + 'battery': 25, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 88, 'errors': list([ dict({ 'Zone': 'Low battery', @@ -130,9 +136,11 @@ }), dict({ 'air_demand': 0, + 'battery': 80, 'coldStage': 1, 'coldStages': 1, 'coldangle': 0, + 'coverage': 66, 'errors': list([ ]), 'floor_demand': 0, @@ -497,9 +505,11 @@ 'temp-set': 19.2, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 99, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 72, }), '1:3': dict({ 'absolute-temp-max': 30.0, @@ -546,9 +556,11 @@ 'temp-set': 19.3, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 35, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 60, }), '1:4': dict({ 'absolute-temp-max': 86.0, @@ -597,9 +609,11 @@ 'temp-set': 66.9, 'temp-step': 1.0, 'temp-unit': 1, + 'thermostat-battery': 25, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 88, }), '1:5': dict({ 'absolute-temp-max': 30.0, @@ -645,9 +659,11 @@ 'temp-set': 19.5, 'temp-step': 0.5, 'temp-unit': 0, + 'thermostat-battery': 80, 'thermostat-fw': '3.33', 'thermostat-model': 'Think (Radio)', 'thermostat-radio': True, + 'thermostat-signal': 66, }), '2:1': dict({ 'absolute-temp-max': 30.0, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 50d1964924d..944ca83d053 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -11,12 +11,14 @@ from aioairzone.const import ( API_ACS_SET_POINT, API_ACS_TEMP, API_AIR_DEMAND, + API_BATTERY, API_COLD_ANGLE, API_COLD_STAGE, API_COLD_STAGES, API_COOL_MAX_TEMP, API_COOL_MIN_TEMP, API_COOL_SET_POINT, + API_COVERAGE, API_DATA, API_ERRORS, API_FLOOR_DEMAND, @@ -119,6 +121,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 99, + API_COVERAGE: 72, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -147,6 +151,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 35, + API_COVERAGE: 60, API_ON: 1, API_MAX_TEMP: 30, API_MIN_TEMP: 15, @@ -173,6 +179,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 25, + API_COVERAGE: 88, API_ON: 0, API_MAX_TEMP: 86, API_MIN_TEMP: 59, @@ -203,6 +211,8 @@ HVAC_MOCK = { API_THERMOS_TYPE: 4, API_THERMOS_FIRMWARE: "3.33", API_THERMOS_RADIO: 1, + API_BATTERY: 80, + API_COVERAGE: 66, API_ON: 0, API_MAX_TEMP: 30, API_MIN_TEMP: 15, From 638b88c61c2974424f1675dce4ea8af8efdde21b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 6 Apr 2025 10:04:18 +0200 Subject: [PATCH 182/217] Only load files ending `.metadata.json` in WebDAV (#142388) --- homeassistant/components/webdav/backup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index fb2927a58bb..a9afb5fe930 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -231,7 +231,7 @@ class WebDavBackupAgent(BackupAgent): return { metadata_content.backup_id: metadata_content for file_name in files - if file_name.endswith(".json") + if file_name.endswith(".metadata.json") if (metadata_content := await _download_metadata(file_name)) } From d7ca168b77756232407da7df4967ae48bf5e7ea3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 00:09:11 -1000 Subject: [PATCH 183/217] Fix flapping logger test (#142367) The websocket_api logger might get adjusted from other tests so we cannot be sure its set at debug in this test --- tests/components/logger/test_websocket_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 5bc280535f9..8fcafcd05a4 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -31,7 +31,6 @@ async def test_integration_log_info( assert msg["type"] == TYPE_RESULT assert msg["success"] assert {"domain": "http", "level": logging.DEBUG} in msg["result"] - assert {"domain": "websocket_api", "level": logging.DEBUG} in msg["result"] async def test_integration_log_level_logger_not_loaded( From 8aee79085ab159ffe90c866f1d7e402f31d00c3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 01:00:41 -1000 Subject: [PATCH 184/217] Bump aioesphomeapi to 29.9.0 (#142393) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.8.0...v29.9.0 fixes #142381 --- homeassistant/components/esphome/manager.py | 4 ++-- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 7ce96a0f510..56c2998a3cc 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -13,7 +13,7 @@ from aioesphomeapi import ( APIConnectionError, APIVersion, DeviceInfo as EsphomeDeviceInfo, - EncryptionHelloAPIError, + EncryptionPlaintextAPIError, EntityInfo, HomeassistantServiceCall, InvalidAuthAPIError, @@ -571,7 +571,7 @@ class ESPHomeManager: if isinstance( err, ( - EncryptionHelloAPIError, + EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index bd81e122981..9f6431c940f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.8.0", + "aioesphomeapi==29.9.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.13.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index a51b5a132ef..658b20f6245 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.8.0 +aioesphomeapi==29.9.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e8aa17b5fe..7a3e632559c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.8.0 +aioesphomeapi==29.9.0 # homeassistant.components.flo aioflo==2021.11.0 From b35a44a0e07b75b225d81a1a4b79c573dbe17249 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sun, 6 Apr 2025 14:46:19 +0200 Subject: [PATCH 185/217] Add sensor platform to eheimdigital (#138809) * Add fan platform to eheimdigital * Fix pylint * Convert fan to sensor platform * Remove unnecessary changes * Add state update test * Review * Review * Review --- .../components/eheimdigital/__init__.py | 2 +- .../components/eheimdigital/icons.json | 18 ++ .../components/eheimdigital/sensor.py | 114 +++++++++++++ .../components/eheimdigital/strings.json | 16 ++ tests/components/eheimdigital/conftest.py | 32 +++- .../eheimdigital/snapshots/test_sensor.ambr | 160 ++++++++++++++++++ tests/components/eheimdigital/test_sensor.py | 77 +++++++++ 7 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/eheimdigital/icons.json create mode 100644 homeassistant/components/eheimdigital/sensor.py create mode 100644 tests/components/eheimdigital/snapshots/test_sensor.ambr create mode 100644 tests/components/eheimdigital/test_sensor.py diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index 26e6bea4d4a..e4fb7989931 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json new file mode 100644 index 00000000000..32f3f1eee9c --- /dev/null +++ b/homeassistant/components/eheimdigital/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "current_speed": { + "default": "mdi:pump" + }, + "service_hours": { + "default": "mdi:wrench-clock" + }, + "error_code": { + "default": "mdi:alert-octagon", + "state": { + "no_error": "mdi:check-circle" + } + } + } + } +} diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py new file mode 100644 index 00000000000..3d809cc14dc --- /dev/null +++ b/homeassistant/components/eheimdigital/sensor.py @@ -0,0 +1,114 @@ +"""EHEIM Digital sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, TypeVar, override + +from eheimdigital.classic_vario import EheimDigitalClassicVario +from eheimdigital.device import EheimDigitalDevice +from eheimdigital.types import FilterErrorCode + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) + + +@dataclass(frozen=True, kw_only=True) +class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]): + """Class describing EHEIM Digital sensor entities.""" + + value_fn: Callable[[_DeviceT_co], float | str | None] + + +CLASSICVARIO_DESCRIPTIONS: tuple[ + EheimDigitalSensorDescription[EheimDigitalClassicVario], ... +] = ( + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="current_speed", + translation_key="current_speed", + value_fn=lambda device: device.current_speed, + native_unit_of_measurement=PERCENTAGE, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="service_hours", + translation_key="service_hours", + value_fn=lambda device: device.service_hours, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_unit_of_measurement=UnitOfTime.DAYS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EheimDigitalSensorDescription[EheimDigitalClassicVario]( + key="error_code", + translation_key="error_code", + value_fn=( + lambda device: device.error_code.name.lower() + if device.error_code is not None + else None + ), + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in FilterErrorCode._member_names_], + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so lights can be added as devices are found.""" + coordinator = entry.runtime_data + + def async_setup_device_entities( + device_address: dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" + entities: list[EheimDigitalSensor[EheimDigitalDevice]] = [] + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicVario): + entities += [ + EheimDigitalSensor[EheimDigitalClassicVario]( + coordinator, device, description + ) + for description in CLASSICVARIO_DESCRIPTIONS + ] + + async_add_entities(entities) + + coordinator.add_platform_callback(async_setup_device_entities) + async_setup_device_entities(coordinator.hub.devices) + + +class EheimDigitalSensor( + EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co] +): + """Represent a EHEIM Digital sensor entity.""" + + entity_description: EheimDigitalSensorDescription[_DeviceT_co] + + def __init__( + self, + coordinator: EheimDigitalUpdateCoordinator, + device: _DeviceT_co, + description: EheimDigitalSensorDescription[_DeviceT_co], + ) -> None: + """Initialize an EHEIM Digital number entity.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = f"{self._device_address}_{description.key}" + + @override + def _async_update_attrs(self) -> None: + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index ef6f6b10d0a..81fa521bbaf 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -46,6 +46,22 @@ } } } + }, + "sensor": { + "current_speed": { + "name": "Current speed" + }, + "service_hours": { + "name": "Remaining hours until service" + }, + "error_code": { + "name": "Error code", + "state": { + "no_error": "No error", + "rotor_stuck": "Rotor stuck", + "air_in_filter": "Air in filter" + } + } } } } diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index ae1bc74df90..2c4af207642 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,9 +4,17 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode +from eheimdigital.types import ( + EheimDeviceType, + FilterErrorCode, + FilterMode, + HeaterMode, + HeaterUnit, + LightMode, +) import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -59,9 +67,28 @@ def heater_mock(): return heater_mock +@pytest.fixture +def classic_vario_mock(): + """Mock a classicVARIO device.""" + classic_vario_mock = MagicMock(spec=EheimDigitalClassicVario) + classic_vario_mock.mac_address = "00:00:00:00:00:03" + classic_vario_mock.device_type = EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + classic_vario_mock.name = "Mock classicVARIO" + classic_vario_mock.aquarium_name = "Mock Aquarium" + classic_vario_mock.sw_version = "1.0.0_1.0.0" + classic_vario_mock.current_speed = 75 + classic_vario_mock.is_active = True + classic_vario_mock.filter_mode = FilterMode.MANUAL + classic_vario_mock.error_code = FilterErrorCode.NO_ERROR + classic_vario_mock.service_hours = 360 + return classic_vario_mock + + @pytest.fixture def eheimdigital_hub_mock( - classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock + classic_led_ctrl_mock: MagicMock, + heater_mock: MagicMock, + classic_vario_mock: MagicMock, ) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( @@ -77,6 +104,7 @@ def eheimdigital_hub_mock( eheimdigital_hub_mock.return_value.devices = { "00:00:00:00:00:01": classic_led_ctrl_mock, "00:00:00:00:00:02": heater_mock, + "00:00:00:00:00:03": classic_vario_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_sensor.ambr b/tests/components/eheimdigital/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c5a3d700331 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_sensor.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.mock_classicvario_current_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current speed', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_speed', + 'unique_id': '00:00:00:00:00:03_current_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock classicVARIO Current speed', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_current_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error code', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error_code', + 'unique_id': '00:00:00:00:00:03_error_code', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock classicVARIO Error code', + 'options': list([ + 'no_error', + 'rotor_stuck', + 'air_in_filter', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_error_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.mock_classicvario_remaining_hours_until_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining hours until service', + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_hours', + 'unique_id': '00:00:00:00:00:03_service_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock classicVARIO Remaining hours until service', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eheimdigital/test_sensor.py b/tests/components/eheimdigital/test_sensor.py new file mode 100644 index 00000000000..ece4d3eb241 --- /dev/null +++ b/tests/components/eheimdigital/test_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the sensor module.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from eheimdigital.types import EheimDeviceType, FilterErrorCode +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 .conftest import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("classic_vario_mock") +async def test_setup_classic_vario( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor platform setup for the filter.""" + mock_config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SENSOR]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + classic_vario_mock: MagicMock, +) -> None: + """Test the sensor state update.""" + await init_integration(hass, mock_config_entry) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO + ) + await hass.async_block_till_done() + + classic_vario_mock.current_speed = 10 + classic_vario_mock.error_code = FilterErrorCode.ROTOR_STUCK + classic_vario_mock.service_hours = 100 + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("sensor.mock_classicvario_current_speed")) + assert state.state == "10" + + assert (state := hass.states.get("sensor.mock_classicvario_error_code")) + assert state.state == "rotor_stuck" + + assert ( + state := hass.states.get( + "sensor.mock_classicvario_remaining_hours_until_service" + ) + ) + assert state.state == str(round(100 / 24, 1)) From 9a897d5e12417ab065c1255522c94a290740f884 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 6 Apr 2025 17:04:34 +0200 Subject: [PATCH 186/217] Update Fritz quality scale (#142411) --- homeassistant/components/fritz/quality_scale.yaml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 805705eb4b4..40cf518d114 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -14,9 +14,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: - status: todo - comment: include the proper docs snippet + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: @@ -31,9 +29,7 @@ rules: action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done - docs-installation-parameters: - status: todo - comment: add the proper configuration_basic block + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -50,7 +46,7 @@ rules: diagnostics: done discovery-update-info: todo discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done docs-known-limitations: status: exempt From e96f2f06fb796691dafc9b5db14bc9e5c95cb085 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 6 Apr 2025 17:28:51 +0200 Subject: [PATCH 187/217] Add parallel updates to Fritz (#142409) * Add parallel updates to Fritz * apply review comment * tweak --- homeassistant/components/fritz/binary_sensor.py | 3 +++ homeassistant/components/fritz/button.py | 3 +++ homeassistant/components/fritz/device_tracker.py | 3 +++ homeassistant/components/fritz/image.py | 3 +++ homeassistant/components/fritz/quality_scale.yaml | 4 +--- homeassistant/components/fritz/sensor.py | 3 +++ homeassistant/components/fritz/switch.py | 3 +++ homeassistant/components/fritz/update.py | 3 +++ 8 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6bc8bb571d4..2a4eb8c82b5 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 74e8ab5e43e..4a5f7e5a443 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -31,6 +31,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzButtonDescription(ButtonEntityDescription): diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index e066219342e..618214a1c55 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -22,6 +22,9 @@ from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d329ec318c5..1fc70dedc6c 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -18,6 +18,9 @@ from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 40cf518d114..29e46b3a0c9 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -33,9 +33,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: not set at the moment, we use a coordinator + parallel-updates: done reauthentication-flow: done test-coverage: status: todo diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 243b3b5eb4c..65a776b9ad5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -32,6 +32,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: """Calculate uptime with deviation.""" diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 8b4816f7451..c00849c5240 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -38,6 +38,9 @@ from .entity import FritzBoxBaseEntity, FritzDeviceBase _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + async def _async_deflection_entities_list( avm_wrapper: AvmWrapper, device_friendly_name: str diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 5d064dc3035..4e54f4c28d3 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) +# Set a sane value to avoid too many updates +PARALLEL_UPDATES = 5 + @dataclass(frozen=True, kw_only=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): From bea389eed74e9031165f93185bf336c95dde3d19 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 6 Apr 2025 18:25:57 +0200 Subject: [PATCH 188/217] Add parallel updates to SamsungTV (#142403) --- homeassistant/components/samsungtv/media_player.py | 3 +++ homeassistant/components/samsungtv/remote.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e6ecfd3593..1c475ee6c25 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -59,6 +59,9 @@ SUPPORT_SAMSUNGTV = ( # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index d6fef262d91..2c6b46c8bb2 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -13,6 +13,9 @@ from .const import LOGGER from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 7c488f1e54508c49a555aba59b7a62841a0e7b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 6 Apr 2025 20:07:46 +0200 Subject: [PATCH 189/217] Add thermostat battery and signal sensors for Airzone integration (#142390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: add thermostat battery/signal sensors Signed-off-by: Álvaro Fernández Rojas * tests: airzone: use snapshot_platform for sensors Signed-off-by: Álvaro Fernández Rojas * airzone: rename sensor strength Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/sensor.py | 16 + homeassistant/components/airzone/strings.json | 3 + .../airzone/snapshots/test_sensor.ambr | 1245 +++++++++++++++++ tests/components/airzone/test_sensor.py | 70 +- tests/components/airzone/util.py | 4 +- 5 files changed, 1286 insertions(+), 52 deletions(-) create mode 100644 tests/components/airzone/snapshots/test_sensor.ambr diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index f76eb1466a3..66657836b74 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -9,6 +9,8 @@ from aioairzone.const import ( AZD_HUMIDITY, AZD_TEMP, AZD_TEMP_UNIT, + AZD_THERMOSTAT_BATTERY, + AZD_THERMOSTAT_SIGNAL, AZD_WEBSERVER, AZD_WIFI_RSSI, AZD_ZONES, @@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + device_class=SensorDeviceClass.BATTERY, + key=AZD_THERMOSTAT_BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_THERMOSTAT_SIGNAL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="thermostat_signal", + ), ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index cd313b821aa..c7d9701aa83 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -76,6 +76,9 @@ "sensor": { "rssi": { "name": "RSSI" + }, + "thermostat_signal": { + "name": "Signal strength" } } } diff --git a/tests/components/airzone/snapshots/test_sensor.ambr b/tests/components/airzone/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..01ebf35b282 --- /dev/null +++ b/tests/components/airzone/snapshots/test_sensor.ambr @@ -0,0 +1,1245 @@ +# serializer version: 1 +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.airzone_2_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Airzone 2:1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.airzone_2_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_2:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_2_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone 2:1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_2_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.3', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.airzone_dhw_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_dhw_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airzone DHW Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airzone_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'airzone_unique_id_ws_wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_airzone_create_sensors[sensor.airzone_webserver_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Airzone WebServer RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.airzone_webserver_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-42', + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.aux_heat_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_4:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.aux_heat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aux Heat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_heat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.despacho_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Despacho Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.despacho_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Despacho Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.despacho_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:4_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Despacho Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.despacho_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.despacho_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:4_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.despacho_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Despacho Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.despacho_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.20', + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dkn_plus_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_3:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dkn_plus_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'DKN Plus Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dkn_plus_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.7', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:3_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #1 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_1_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:3_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm #2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_2_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm #2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:5_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm #2 Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_2_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:5_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm #2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.5', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_ppal_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_thermostat-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dorm Ppal Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_ppal_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dorm Ppal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_signal', + 'unique_id': 'airzone_unique_id_1:2_thermostat-signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dorm Ppal Signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72', + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.dorm_ppal_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:2_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.dorm_ppal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Dorm Ppal Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dorm_ppal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.salon_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Salon Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.salon_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.salon_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airzone', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'airzone_unique_id_1:1_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_airzone_create_sensors[sensor.salon_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Salon Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.salon_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.6', + }) +# --- diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 352994d6313..b226be8ac78 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,17 @@ """The sensor tests for the Airzone platform.""" +from collections.abc import Generator import copy from unittest.mock import patch from aioairzone.const import API_DATA, API_SYSTEMS import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.airzone.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from .util import ( @@ -20,62 +23,27 @@ from .util import ( async_init_integration, ) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.airzone.PLATFORMS", [Platform.SENSOR]): + yield @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_airzone_create_sensors(hass: HomeAssistant) -> None: +async def test_airzone_create_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test creation of sensors.""" - await async_init_integration(hass) + config_entry = await async_init_integration(hass) - # Hot Water - state = hass.states.get("sensor.airzone_dhw_temperature") - assert state.state == "43" - - # WebServer - state = hass.states.get("sensor.airzone_webserver_rssi") - assert state.state == "-42" - - # Zones - state = hass.states.get("sensor.despacho_temperature") - assert state.state == "21.20" - - state = hass.states.get("sensor.despacho_humidity") - assert state.state == "36" - - state = hass.states.get("sensor.dorm_1_temperature") - assert state.state == "20.8" - - state = hass.states.get("sensor.dorm_1_humidity") - assert state.state == "35" - - state = hass.states.get("sensor.dorm_2_temperature") - assert state.state == "20.5" - - state = hass.states.get("sensor.dorm_2_humidity") - assert state.state == "40" - - state = hass.states.get("sensor.dorm_ppal_temperature") - assert state.state == "21.1" - - state = hass.states.get("sensor.dorm_ppal_humidity") - assert state.state == "39" - - state = hass.states.get("sensor.salon_temperature") - assert state.state == "19.6" - - state = hass.states.get("sensor.salon_humidity") - assert state.state == "34" - - state = hass.states.get("sensor.airzone_2_1_temperature") - assert state.state == "22.3" - - state = hass.states.get("sensor.airzone_2_1_humidity") - assert state.state == "62" - - state = hass.states.get("sensor.dkn_plus_temperature") - assert state.state == "21.7" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) state = hass.states.get("sensor.dkn_plus_humidity") assert state is None diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 944ca83d053..55cb32b67a5 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -371,7 +371,7 @@ HVAC_WEBSERVER_MOCK = { async def async_init_integration( hass: HomeAssistant, -) -> None: +) -> MockConfigEntry: """Set up the Airzone integration in Home Assistant.""" config_entry = MockConfigEntry( @@ -407,3 +407,5 @@ async def async_init_integration( ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + return config_entry From a44adf2e6f49c12dfa3b104e7eacad2678e413e1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 09:17:21 +0200 Subject: [PATCH 190/217] Use common states for `battery_critical` in `nuki` (#142349) Replace "on": "Low" and "off": "Normal" with common states. This will allow us to use the common states in the `binary_sensor` class, too. --- homeassistant/components/nuki/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index daf47bc7de1..84e66c3db96 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -48,8 +48,8 @@ "state_attributes": { "battery_critical": { "state": { - "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]", - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]" + "on": "[%key:common::state::low%]", + "off": "[%key:common::state::normal%]" } } } From 3e4a077862bd9ad62c70bde9130fd87e75fa8087 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 7 Apr 2025 09:35:44 +0200 Subject: [PATCH 191/217] Fix Reolink smart AI sensors (#142454) --- homeassistant/components/reolink/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 39910bbc52a..95c5f1982c3 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -301,7 +301,7 @@ async def async_setup_entry( ) for entity_description in BINARY_SMART_AI_SENSORS for location in api.baichuan.smart_location_list( - channel, entity_description.key + channel, entity_description.smart_type ) if entity_description.supported(api, channel, location) ) From 056d26f13c5800312088287d23021733c7a060f8 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 7 Apr 2025 17:38:50 +1000 Subject: [PATCH 192/217] Set parallel updates for SMLIGHT entities (#142455) Set parallel updates for entities --- homeassistant/components/smlight/binary_sensor.py | 1 + homeassistant/components/smlight/button.py | 2 ++ homeassistant/components/smlight/sensor.py | 2 ++ homeassistant/components/smlight/switch.py | 2 ++ homeassistant/components/smlight/update.py | 2 ++ 5 files changed, 9 insertions(+) diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index ce3457ae81b..aaba15e19f2 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -22,6 +22,7 @@ from .const import SCAN_INTERNET_INTERVAL from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 SCAN_INTERVAL = SCAN_INTERNET_INTERVAL diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 5caf43b7cba..f834392ea13 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -23,6 +23,8 @@ from .const import DOMAIN from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 2f57843b5eb..f045d009a00 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -25,6 +25,8 @@ from .const import UPTIME_DEVIATION from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class SmSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 09d2714956c..5cd187c009c 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -22,6 +22,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 3143f2f4290..48f9149645c 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -26,6 +26,8 @@ from .const import LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity +PARALLEL_UPDATES = 1 + def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None: """Get the latest Zigbee firmware version.""" From 1e104ba40b100f978c4486f30d1f57b693cf89e6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 7 Apr 2025 09:40:06 +0200 Subject: [PATCH 193/217] Add missing strings to SamsungTV (#142405) --- .../components/samsungtv/strings.json | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index c9d08f756d0..d08e2a843ba 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -9,7 +9,8 @@ "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "host": "The hostname or IP address of your TV." + "host": "The hostname or IP address of your TV.", + "name": "The name of your TV. This will be used to identify the device in Home Assistant." } }, "confirm": { @@ -22,10 +23,22 @@ "description": "After submitting, accept the popup on {device} requesting authorization within 30 seconds or input PIN." }, "encrypted_pairing": { - "description": "Please enter the PIN displayed on {device}." + "description": "Please enter the PIN displayed on {device}.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN displayed on your TV." + } }, "reauth_confirm_encrypted": { - "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]" + "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::samsungtv::config::step::encrypted_pairing::data_description::pin%]" + } } }, "error": { From 43f93c74daf461a9f582cf31c1e7557512efc917 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 09:54:57 +0200 Subject: [PATCH 194/217] Use common state for "Normal" in `matter` (#142452) --- homeassistant/components/matter/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 4fa49f887d9..54db8c695e6 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -222,7 +222,7 @@ "name": "Number of rinses", "state": { "off": "[%key:common::state::off%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "extra": "Extra", "max": "Max" } @@ -238,7 +238,7 @@ "contamination_state": { "name": "Contamination state", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "low": "[%key:common::state::low%]", "warning": "Warning", "critical": "Critical" From 8d82ef8e3654568b22b26432160822784feec71b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 22:11:15 -1000 Subject: [PATCH 195/217] Fix HKC showing hvac_action as idle when fan is active and heat cool target is off (#142443) * Fix HKC showing hvac_action as idle when fan is active and heat cool target is off fixes #142442 * comment relocation --- .../components/homekit_controller/climate.py | 12 +- .../fixtures/ecobee3_lite.json | 3436 ++++++++++++++++ .../snapshots/test_init.ambr | 3458 +++++++++++++++++ .../homekit_controller/test_climate.py | 23 +- 4 files changed, 6921 insertions(+), 8 deletions(-) create mode 100644 tests/components/homekit_controller/fixtures/ecobee3_lite.json diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 7341bbd3a4a..4c8bf8517be 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -659,13 +659,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. # Can be 0 - 2 (Off, Heat, Cool) - # If the HVAC is switched off, it must be idle - # This works around a bug in some devices (like Eve radiator valves) that - # return they are heating when they are not. target = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) - if target == HeatingCoolingTargetValues.OFF: - return HVACAction.IDLE - value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT) current_hass_value = CURRENT_MODE_HOMEKIT_TO_HASS.get(value) @@ -679,6 +673,12 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): ): return HVACAction.FAN + # If the HVAC is switched off, it must be idle + # This works around a bug in some devices (like Eve radiator valves) that + # return they are heating when they are not. + if target == HeatingCoolingTargetValues.OFF: + return HVACAction.IDLE + return current_hass_value @property diff --git a/tests/components/homekit_controller/fixtures/ecobee3_lite.json b/tests/components/homekit_controller/fixtures/ecobee3_lite.json new file mode 100644 index 00000000000..0656ed20fdb --- /dev/null +++ b/tests/components/homekit_controller/fixtures/ecobee3_lite.json @@ -0,0 +1,3436 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "ecobee3 lite", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "4.8.70226", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "iid": 11, + "perms": ["pr", "hd"], + "format": "string", + "value": "4.1;3fac0fb4", + "maxLen": 64 + }, + { + "type": "00000220-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "hd"], + "format": "data", + "value": "u4qz9YgSXzQ=" + }, + { + "type": "000000A6-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "ev"], + "format": "uint32", + "value": 0, + "description": "Accessory Flags" + } + ] + }, + { + "iid": 30, + "type": "000000A2-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "iid": 31, + "perms": ["pr"], + "format": "string", + "value": "1.1.0", + "description": "Version", + "maxLen": 64 + } + ] + }, + { + "iid": 16, + "type": "0000004A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000000F-0000-1000-8000-0026BB765291", + "iid": 17, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Current Heating Cooling State", + "minValue": 0, + "maxValue": 2, + "minStep": 1, + "valid-values": [0, 1, 2] + }, + { + "type": "00000033-0000-1000-8000-0026BB765291", + "iid": 18, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Heating Cooling State", + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "valid-values": [0, 1, 2, 3] + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 19, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.2, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 40.0, + "minStep": 0.1 + }, + { + "type": "00000035-0000-1000-8000-0026BB765291", + "iid": 20, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Target Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000036-0000-1000-8000-0026BB765291", + "iid": 21, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 1, + "description": "Temperature Display Units", + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "valid-values": [0, 1] + }, + { + "type": "0000000D-0000-1000-8000-0026BB765291", + "iid": 22, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 25.0, + "description": "Cooling Threshold Temperature", + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "00000012-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 22.2, + "description": "Heating Threshold Temperature", + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "iid": 24, + "perms": ["pr", "ev"], + "format": "float", + "value": 45.0, + "description": "Current Relative Humidity", + "unit": "percentage", + "minValue": 0, + "maxValue": 100.0, + "minStep": 1.0 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 27, + "perms": ["pr"], + "format": "string", + "value": "Thermostat", + "description": "Name", + "maxLen": 64 + }, + { + "type": "000000BF-0000-1000-8000-0026BB765291", + "iid": 75, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 0, + "description": "Target Fan State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "000000AF-0000-1000-8000-0026BB765291", + "iid": 76, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Current Fan State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "B7DDB9A3-54BB-4572-91D2-F1F5B0510F8C", + "iid": 33, + "perms": ["pr"], + "format": "uint8", + "value": 3, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "E4489BBC-5227-4569-93E5-B345E3E5508F", + "iid": 34, + "perms": ["pr", "pw"], + "format": "float", + "value": 22.2, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "7D381BAA-20F9-40E5-9BE9-AEB92D4BECEF", + "iid": 35, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.0, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "73AAB542-892A-4439-879A-D2A883724B69", + "iid": 36, + "perms": ["pr", "pw"], + "format": "float", + "value": 17.8, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "5DA985F0-898A-4850-B987-B76C6C78D670", + "iid": 37, + "perms": ["pr", "pw"], + "format": "float", + "value": 25.6, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "05B97374-6DC0-439B-A0FA-CA33F612D425", + "iid": 38, + "perms": ["pr", "pw"], + "format": "float", + "value": 20.0, + "unit": "celsius", + "minValue": 7.2, + "maxValue": 26.1, + "minStep": 0.1 + }, + { + "type": "A251F6E7-AC46-4190-9C5D-3D06277BDF9F", + "iid": 39, + "perms": ["pr", "pw"], + "format": "float", + "value": 24.4, + "unit": "celsius", + "minValue": 18.3, + "maxValue": 33.3, + "minStep": 0.1 + }, + { + "type": "1B300BC2-CFFC-47FF-89F9-BD6CCF5F2853", + "iid": 40, + "perms": ["pw"], + "format": "uint8", + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "1621F556-1367-443C-AF19-82AF018E99DE", + "iid": 41, + "perms": ["pr", "pw"], + "format": "string", + "value": "2025-04-06T23:30:00-05:00R", + "maxLen": 64 + }, + { + "type": "FA128DE6-9D7D-49A4-B6D8-4E4E234DEE38", + "iid": 48, + "perms": ["pw"], + "format": "bool" + }, + { + "type": "4A6AE4F6-036C-495D-87CC-B3702B437741", + "iid": 49, + "perms": ["pr"], + "format": "uint8", + "value": 1, + "minValue": 0, + "maxValue": 4, + "minStep": 1 + }, + { + "type": "DB7BF261-7042-4194-8BD1-3AA22830AEDD", + "iid": 50, + "perms": ["pr"], + "format": "uint8", + "value": 0, + "minValue": 0, + "maxValue": 3, + "minStep": 1 + }, + { + "type": "41935E3E-B54D-42E9-B8B9-D33C6319F0AF", + "iid": 51, + "perms": ["pr"], + "format": "bool", + "value": false + }, + { + "type": "C35DA3C0-E004-40E3-B153-46655CDD9214", + "iid": 52, + "perms": ["pr", "pw"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "48F62AEC-4171-4B4A-8F0E-1EEB6708B3FB", + "iid": 53, + "perms": ["pr"], + "format": "uint8", + "value": 100, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "1B1515F2-CC45-409F-991F-C480987F92C3", + "iid": 54, + "perms": ["pr"], + "format": "string", + "value": "The Hive is humming along. You have no pending alerts or reminders.", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4295608971, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 22.4, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Master BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 691, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295608960, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 20.3, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Basement Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9158, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016858, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.0, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Living Room Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4295016969, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBRSE4", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 208, + "type": "0000008A-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 209, + "perms": ["pr", "ev"], + "format": "float", + "value": 21.6, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 100.0, + "minStep": 0.1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 210, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Temperature", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 211, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 212, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + } + ] + }, + { + "aid": 4298584118, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1421, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 821, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Master BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298649931, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Loft window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Loft window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 327, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Loft window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 328, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Loft window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527970, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Front Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Front Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1473, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Front Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 873, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Front Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298527962, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Garage Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1189, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 888, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Garage Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360914, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": -1, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Basement Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360921, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Deck Door", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 944, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 884, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Deck Door Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298360712, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 1, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 1923, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 625, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Living Room Window 1 Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + }, + { + "aid": 4298568508, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "ecobee Inc.", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "EBDWC01", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + } + ] + }, + { + "iid": 192, + "type": "00000096-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000008F-0000-1000-8000-0026BB765291", + "iid": 193, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 2, + "description": "Charging State", + "minValue": 0, + "maxValue": 2, + "minStep": 1 + }, + { + "type": "00000068-0000-1000-8000-0026BB765291", + "iid": 194, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 100, + "description": "Battery Level", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 195, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 196, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window", + "description": "Name", + "maxLen": 64 + } + ] + }, + { + "iid": 57, + "type": "00000086-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000071-0000-1000-8000-0026BB765291", + "iid": 65, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Occupancy Detected", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 29, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Occupancy", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 117, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 116, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "A8F798E0-4A40-11E6-BDF4-0800200C9A66", + "iid": 68, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 56, + "type": "00000085-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000022-0000-1000-8000-0026BB765291", + "iid": 66, + "perms": ["pr", "ev"], + "format": "bool", + "value": false, + "description": "Motion Detected" + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 28, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Motion", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000075-0000-1000-8000-0026BB765291", + "iid": 101, + "perms": ["pr", "ev"], + "format": "bool", + "value": true, + "description": "Status Active" + }, + { + "type": "00000079-0000-1000-8000-0026BB765291", + "iid": 100, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Status Low Battery", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "iid": 67, + "perms": ["pr"], + "format": "int", + "value": 9060, + "unit": "seconds", + "minValue": -1, + "maxValue": 86400, + "minStep": 1 + } + ] + }, + { + "iid": 224, + "type": "00000080-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "0000006A-0000-1000-8000-0026BB765291", + "iid": 225, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 0, + "description": "Contact Sensor State", + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 226, + "perms": ["pr"], + "format": "string", + "value": "Upstairs BR Window Contact", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 62b53df33f2..3bb9eb48106 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -4195,6 +4195,3464 @@ }), ]) # --- +# name: test_snapshots[ecobee3_lite] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295608960', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Motion', + }), + 'entity_id': 'binary_sensor.basement_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Occupancy', + }), + 'entity_id': 'binary_sensor.basement_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608960_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.basement_temperature', + 'state': '20.3', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360914', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Basement Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Basement Window 1 Contact', + }), + 'entity_id': 'binary_sensor.basement_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement Window 1 Motion', + }), + 'entity_id': 'binary_sensor.basement_window_1_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Basement Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.basement_window_1_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Basement Window 1 Identify', + }), + 'entity_id': 'button.basement_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Basement Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360914_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.basement_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360921', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Deck Door Contact', + }), + 'entity_id': 'binary_sensor.deck_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Deck Door Motion', + }), + 'entity_id': 'binary_sensor.deck_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Deck Door Occupancy', + }), + 'entity_id': 'binary_sensor.deck_door_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.deck_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Deck Door Identify', + }), + 'entity_id': 'button.deck_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Deck Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360921_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.deck_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298527970', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Front Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Front Door Contact', + }), + 'entity_id': 'binary_sensor.front_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Front Door Motion', + }), + 'entity_id': 'binary_sensor.front_door_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Front Door Occupancy', + }), + 'entity_id': 'binary_sensor.front_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.front_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Front Door Identify', + }), + 'entity_id': 'button.front_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Front Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527970_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.front_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298527962', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Garage Door', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Garage Door Contact', + }), + 'entity_id': 'binary_sensor.garage_door_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Garage Door Motion', + }), + 'entity_id': 'binary_sensor.garage_door_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Garage Door Occupancy', + }), + 'entity_id': 'binary_sensor.garage_door_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.garage_door_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Garage Door Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Garage Door Identify', + }), + 'entity_id': 'button.garage_door_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garage_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Garage Door Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298527962_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Door Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.garage_door_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295016858', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Living Room', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Motion', + }), + 'entity_id': 'binary_sensor.living_room_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Identify', + }), + 'entity_id': 'button.living_room_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016858_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.living_room_temperature', + 'state': '21.0', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298360712', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Living Room Window 1', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Living Room Window 1 Contact', + }), + 'entity_id': 'binary_sensor.living_room_window_1_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Living Room Window 1 Motion', + }), + 'entity_id': 'binary_sensor.living_room_window_1_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Living Room Window 1 Occupancy', + }), + 'entity_id': 'binary_sensor.living_room_window_1_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_window_1_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living Room Window 1 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Living Room Window 1 Identify', + }), + 'entity_id': 'button.living_room_window_1_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_window_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Living Room Window 1 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298360712_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Window 1 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.living_room_window_1_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298649931', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Loft window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Loft window Contact', + }), + 'entity_id': 'binary_sensor.loft_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Loft window Motion', + }), + 'entity_id': 'binary_sensor.loft_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Loft window Occupancy', + }), + 'entity_id': 'binary_sensor.loft_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.loft_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Loft window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Loft window Identify', + }), + 'entity_id': 'button.loft_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.loft_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Loft window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298649931_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Loft window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.loft_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295608971', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Master BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Motion', + }), + 'entity_id': 'binary_sensor.master_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Identify', + }), + 'entity_id': 'button.master_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295608971_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Master BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.master_br_temperature', + 'state': '22.4', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298584118', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Master BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Master BR Window Contact', + }), + 'entity_id': 'binary_sensor.master_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master BR Window Motion', + }), + 'entity_id': 'binary_sensor.master_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.master_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Master BR Window Identify', + }), + 'entity_id': 'button.master_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298584118_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3 lite', + 'model_id': None, + 'name': 'Thermostat', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '4.8.70226', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.thermostat_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Clear Hold', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Clear Hold', + }), + 'entity_id': 'button.thermostat_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.thermostat_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Thermostat Identify', + }), + 'entity_id': 'button.thermostat_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 45.0, + 'current_temperature': 21.2, + 'fan_mode': 'on', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'entity_id': 'climate.thermostat', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.thermostat_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Current Mode', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.thermostat_current_mode', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.thermostat_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Thermostat Temperature Display Units', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.thermostat_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Thermostat Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.thermostat_current_humidity', + 'state': '45.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermostat Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.thermostat_current_temperature', + 'state': '21.2', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4295016969', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBRSE4', + 'model_id': None, + 'name': 'Upstairs BR', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Identify', + }), + 'entity_id': 'button.upstairs_br_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_battery', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.upstairs_br_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4295016969_208', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Upstairs BR Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.upstairs_br_temperature', + 'state': '21.6', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4298568508', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'ecobee Inc.', + 'model': 'EBDWC01', + 'model_id': None, + 'name': 'Upstairs BR Window', + 'name_by_user': None, + 'primary_config_entry': 'TestData', + 'serial_number': '**REDACTED**', + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Contact', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_224', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Upstairs BR Window Contact', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_contact', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Motion', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Upstairs BR Window Motion', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Occupancy', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Upstairs BR Window Occupancy', + }), + 'entity_id': 'binary_sensor.upstairs_br_window_occupancy', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.upstairs_br_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upstairs BR Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'Upstairs BR Window Identify', + }), + 'entity_id': 'button.upstairs_br_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.upstairs_br_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Upstairs BR Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4298568508_192', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs BR Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.upstairs_br_window_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[ecobee3_no_sensors] list([ dict({ diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 62c73af9977..b119b5f7b80 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -8,6 +8,8 @@ from aiohomekit.model.characteristics import ( CharacteristicsTypes, CurrentFanStateValues, CurrentHeaterCoolerStateValues, + HeatingCoolingCurrentValues, + HeatingCoolingTargetValues, SwingModeValues, TargetHeaterCoolerStateValues, ) @@ -20,6 +22,7 @@ from homeassistant.components.climate import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACAction, HVACMode, ) from homeassistant.core import HomeAssistant @@ -662,7 +665,7 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "fan" + assert state.attributes["hvac_action"] == HVACAction.FAN # Simulate that current temperature is below target temp # Heating might be on and hvac_action currently 'heat' @@ -676,7 +679,23 @@ async def test_hvac_mode_vs_hvac_action( state = await helper.poll_and_get_state() assert state.state == "heat" - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == HVACAction.HEATING + + # If the fan is active, and the heating is off, the hvac_action should be 'fan' + # and not 'idle' or 'heating' + await helper.async_update( + ServicesTypes.THERMOSTAT, + { + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + CharacteristicsTypes.HEATING_COOLING_CURRENT: HeatingCoolingCurrentValues.IDLE, + CharacteristicsTypes.HEATING_COOLING_TARGET: HeatingCoolingTargetValues.OFF, + CharacteristicsTypes.FAN_STATE_CURRENT: CurrentFanStateValues.ACTIVE, + }, + ) + + state = await helper.poll_and_get_state() + assert state.state == HVACMode.OFF + assert state.attributes["hvac_action"] == HVACAction.FAN async def test_hvac_mode_vs_hvac_action_current_mode_wrong( From 04dfa45db0c4e000ef889545bb7e61a6692f862e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Apr 2025 22:18:46 -1000 Subject: [PATCH 196/217] Add GATT polling support to INKBird (#142307) * Add GATT polling support to INKBird * reduce * fixes * coverage * dry * reduce * reduce --- homeassistant/components/inkbird/__init__.py | 107 ++++++++++++++----- tests/components/inkbird/__init__.py | 12 +++ tests/components/inkbird/test_sensor.py | 98 ++++++++++++++++- 3 files changed, 190 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 9dd058e841a..467fa2445e8 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime, timedelta import logging from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate @@ -9,13 +10,16 @@ from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfo, + BluetoothServiceInfoBleak, + async_ble_device_from_address, ) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_interval from .const import CONF_DEVICE_TYPE, DOMAIN @@ -23,34 +27,87 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +FALLBACK_POLL_INTERVAL = timedelta(seconds=180) + + +class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): + """Coordinator for INKBIRD Bluetooth devices.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + data: INKBIRDBluetoothDeviceData, + ) -> None: + """Initialize the INKBIRD Bluetooth processor coordinator.""" + self._data = data + self._entry = entry + address = entry.unique_id + assert address is not None + entry.async_on_unload( + async_track_time_interval( + hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) + super().__init__( + hass=hass, + logger=_LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=self._async_on_update, + needs_poll_method=self._async_needs_poll, + poll_method=self._async_poll_data, + ) + + async def _async_poll_data( + self, last_service_info: BluetoothServiceInfoBleak + ) -> SensorUpdate: + """Poll the device.""" + return await self._data.async_poll(last_service_info.device) + + @callback + def _async_needs_poll( + self, service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + not self.hass.is_stopping + and self._data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + @callback + def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + update = self._data.update(service_info) + if ( + self._entry.data.get(CONF_DEVICE_TYPE) is None + and self._data.device_type is not None + ): + device_type_str = str(self._data.device_type) + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, + ) + return update + + @callback + def _async_schedule_poll(self, _: datetime) -> None: + """Schedule a poll of the device.""" + if self._last_service_info and self._async_needs_poll( + self._last_service_info, self._last_poll + ): + self._debounced_poll.async_schedule_call() + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" - address = entry.unique_id - assert address is not None device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) data = INKBIRDBluetoothDeviceData(device_type) - - @callback - def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate: - """Handle update callback from the passive BLE processor.""" - nonlocal device_type - update = data.update(service_info) - if device_type is None and data.device_type is not None: - device_type_str = str(data.device_type) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str} - ) - device_type = device_type_str - return update - - coordinator = PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=_async_on_update, - ) + coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 01ae0bf8efc..e285e1cbf2d 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -22,6 +22,18 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( source="local", ) + +SPS_PASSIVE_SERVICE_INFO = BluetoothServiceInfo( + name="sps", + address="AA:BB:CC:DD:EE:FF", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( name="XXXXcorruptXXXX", address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 0f3d6497c2b..00b76366b48 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,16 +1,63 @@ """Test the INKBIRD config flow.""" +from unittest.mock import patch + +from inkbird_ble import ( + DeviceKey, + SensorDescription, + SensorDeviceInfo, + SensorUpdate, + SensorValue, + Units, +) +from sensor_state_data import SensorDeviceClass + +from homeassistant.components.inkbird import FALLBACK_POLL_INTERVAL from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO +from . import ( + SPS_PASSIVE_SERVICE_INFO, + SPS_SERVICE_INFO, + SPS_WITH_CORRUPT_NAME_SERVICE_INFO, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import inject_bluetooth_service_info +def _make_sensor_update(humidity: float) -> SensorUpdate: + return SensorUpdate( + title=None, + devices={ + None: SensorDeviceInfo( + name="IBS-TH EEFF", + model="IBS-TH", + manufacturer="INKBIRD", + sw_version=None, + hw_version=None, + ) + }, + entity_descriptions={ + DeviceKey(key="humidity", device_id=None): SensorDescription( + device_key=DeviceKey(key="humidity", device_id=None), + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=Units.PERCENTAGE, + ), + }, + entity_values={ + DeviceKey(key="humidity", device_id=None): SensorValue( + device_key=DeviceKey(key="humidity", device_id=None), + name="Humidity", + native_value=humidity, + ), + }, + ) + + async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( @@ -68,3 +115,50 @@ async def test_device_with_corrupt_name(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_polling_sensor(hass: HomeAssistant) -> None: + """Test setting up a device that needs polling.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + data={CONF_DEVICE_TYPE: "IBS-TH"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + with patch( + "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update(10.24), + ): + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" + + with patch( + "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update(20.24), + ): + async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL) + inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "20.24" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 2ed70ef24111d1f1edb4874dcab8b096d53f3029 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Apr 2025 12:27:15 +0200 Subject: [PATCH 197/217] Use mock_config_flow test helper in config tests (#142461) --- .../components/config/test_config_entries.py | 63 ++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index ce10a36c42c..c6e65c312bb 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import config_entries -from homeassistant.config_entries import HANDLERS, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType @@ -34,13 +34,6 @@ from tests.common import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator -@pytest.fixture -def clear_handlers() -> Generator[None]: - """Clear config entry handlers.""" - with patch.dict(HANDLERS, clear=True): - yield - - @pytest.fixture(autouse=True) def mock_test_component(hass: HomeAssistant) -> None: """Ensure a component called 'test' exists.""" @@ -74,7 +67,7 @@ def mock_flow() -> Generator[None]: @pytest.mark.usefixtures("freezer") -@pytest.mark.usefixtures("clear_handlers", "mock_flow") +@pytest.mark.usefixtures("mock_flow") async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: """Test get entries.""" mock_integration(hass, MockModule("comp1")) @@ -358,7 +351,7 @@ async def test_reload_entry_in_setup_retry( entry.add_to_hass(hass) hass.config.components.add("comp") - with patch.dict(HANDLERS, {"comp": ConfigFlow, "test": ConfigFlow}): + with mock_config_flow("comp", ConfigFlow), mock_config_flow("test", ConfigFlow): resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -422,7 +415,7 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "show_advanced_options": True}, @@ -471,7 +464,7 @@ async def test_initialize_flow_unmet_dependency( async def async_step_user(self, user_input=None): pass - with patch.dict(HANDLERS, {"test2": TestFlow}): + with mock_config_flow("test2", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test2", "show_advanced_options": True}, @@ -502,7 +495,7 @@ async def test_initialize_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -519,7 +512,7 @@ async def test_abort(hass: HomeAssistant, client: TestClient) -> None: async def async_step_user(self, user_input=None): return self.async_abort(reason="bla") - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -552,7 +545,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -620,7 +613,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -638,7 +631,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={"user_title": "user-title"}, @@ -707,7 +700,7 @@ async def test_continue_flow_unauth( title=user_input["user_title"], data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -774,7 +767,7 @@ async def test_get_progress_index( assert self._get_reconfigure_entry() is entry return await self.async_step_account() - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): form_hassio = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_HASSIO} ) @@ -838,7 +831,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -874,7 +867,7 @@ async def test_get_progress_flow_unauth( errors={"username": "Should be unique."}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -918,7 +911,7 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -980,7 +973,7 @@ async def test_options_flow_unauth( hass_admin_user.groups = [] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) assert resp.status == HTTPStatus.UNAUTHORIZED @@ -1017,7 +1010,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1035,7 +1028,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client: TestClient) -> "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"enabled": True}, @@ -1092,7 +1085,7 @@ async def test_options_flow_with_invalid_data( ).add_to_hass(hass) entry = hass.config_entries.async_entries()[0] - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): url = "/api/config/config_entries/options/flow" resp = await client.post(url, json={"handler": entry.entry_id}) @@ -1118,7 +1111,7 @@ async def test_options_flow_with_invalid_data( "preview": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/options/flow/{flow_id}", json={"choices": ["valid", "invalid"]}, @@ -1812,7 +1805,7 @@ async def test_ignore_flow( ws_client = await hass_ws_client(hass) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): result = await hass.config_entries.flow.async_init( "test", context={"source": core_ce.SOURCE_USER} | flow_context ) @@ -1861,7 +1854,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -@pytest.mark.usefixtures("clear_handlers", "freezer") +@pytest.mark.usefixtures("freezer") async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2313,7 +2306,6 @@ async def test_get_matching_entries_ws( assert response["success"] is False -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2532,7 +2524,6 @@ async def test_subscribe_entries_ws( ] -@pytest.mark.usefixtures("clear_handlers") async def test_subscribe_entries_ws_filtered( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -2792,7 +2783,7 @@ async def test_flow_with_multiple_schema_errors( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2834,7 +2825,7 @@ async def test_flow_with_multiple_schema_errors_base( ), ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test"} ) @@ -2893,7 +2884,7 @@ async def test_supports_reconfigure( data={"secret": "account_token"}, ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, @@ -2915,7 +2906,7 @@ async def test_supports_reconfigure( "errors": None, } - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( f"/api/config/config_entries/flow/{flow_id}", json={}, @@ -2953,7 +2944,7 @@ async def test_does_not_support_reconfigure( title="Test Entry", data={"secret": "account_token"} ) - with patch.dict(HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): resp = await client.post( "/api/config/config_entries/flow", json={"handler": "test", "entry_id": "1"}, From 33fa8df73e966f9f2e39bf35332e4a703c8b2646 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Apr 2025 13:28:09 +0200 Subject: [PATCH 198/217] Remove `ConfigEntriesFlowManager.async_post_init` (#142463) Remove ConfigEntriesFlowManager.async_post_init --- homeassistant/config_entries.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 016b199744c..ef1865da4be 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1367,12 +1367,12 @@ class ConfigEntriesFlowManager( self._initialize_futures: defaultdict[str, set[asyncio.Future[None]]] = ( defaultdict(set) ) - self._discovery_debouncer = Debouncer[None]( + self._discovery_event_debouncer = Debouncer[None]( hass, _LOGGER, cooldown=DISCOVERY_COOLDOWN, immediate=True, - function=self._async_discovery, + function=self._async_fire_discovery_event, background=True, ) @@ -1454,8 +1454,12 @@ class ConfigEntriesFlowManager( if not self._pending_import_flows[handler]: del self._pending_import_flows[handler] - if result["type"] != data_entry_flow.FlowResultType.ABORT: - await self.async_post_init(flow, result) + if ( + result["type"] != data_entry_flow.FlowResultType.ABORT + and source in DISCOVERY_SOURCES + ): + # Fire discovery event + await self._discovery_event_debouncer.async_call() return result @@ -1497,7 +1501,7 @@ class ConfigEntriesFlowManager( for future_list in self._initialize_futures.values(): for future in future_list: future.set_result(None) - self._discovery_debouncer.async_shutdown() + self._discovery_event_debouncer.async_shutdown() async def async_finish_flow( self, @@ -1691,21 +1695,9 @@ class ConfigEntriesFlowManager( flow.init_step = context["source"] return flow - async def async_post_init( - self, - flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], - result: ConfigFlowResult, - ) -> None: - """After a flow is initialised trigger new flow notifications.""" - source = flow.context["source"] - - # Create notification. - if source in DISCOVERY_SOURCES: - await self._discovery_debouncer.async_call() - @callback - def _async_discovery(self) -> None: - """Handle discovery.""" + def _async_fire_discovery_event(self) -> None: + """Fire discovery event.""" # async_fire_internal is used here because this is only # called from the Debouncer so we know the usage is safe self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED) From a026820483e5569304c3d3f83d5ed205b68d9c0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Apr 2025 13:28:27 +0200 Subject: [PATCH 199/217] Remove FlowManager.async_post_init (#142462) --- homeassistant/data_entry_flow.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index f7be891b61b..511bab25a7f 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -219,13 +219,6 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): FlowResultType.CREATE_ENTRY. """ - async def async_post_init( - self, - flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], - result: _FlowResultT, - ) -> None: - """Entry has finished executing its first step asynchronously.""" - @callback def async_get(self, flow_id: str) -> _FlowResultT: """Return a flow in progress as a partial FlowResult.""" @@ -312,12 +305,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.init_data = data self._async_add_flow_progress(flow) - result = await self._async_handle_step(flow, flow.init_step, data) - - if result["type"] != FlowResultType.ABORT: - await self.async_post_init(flow, result) - - return result + return await self._async_handle_step(flow, flow.init_step, data) async def async_configure( self, flow_id: str, user_input: dict | None = None From 2818f746344d73f4c25b36eb9bb2bd69242f63c6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 14:05:28 +0200 Subject: [PATCH 200/217] Use common states for "Normal" and "Low" in `binary_sensor` (#142465) * Use common state for "Normal" in `binary_sensor` Replace the "Normal" string for `battery` and the two references to it from `heat` and `cold` to it with the common state. * Use common state for "Low" in `binary_sensor` --- homeassistant/components/binary_sensor/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 9fac758e168..ea897ed1c49 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -124,8 +124,8 @@ "battery": { "name": "Battery", "state": { - "off": "Normal", - "on": "Low" + "off": "[%key:common::state::normal%]", + "on": "[%key:common::state::low%]" } }, "battery_charging": { @@ -145,7 +145,7 @@ "cold": { "name": "Cold", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Cold" } }, @@ -180,7 +180,7 @@ "heat": { "name": "Heat", "state": { - "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "off": "[%key:common::state::normal%]", "on": "Hot" } }, From 4020c987b51f9b62fb25412d438cc4e8de4a11c3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 14:06:52 +0200 Subject: [PATCH 201/217] Use common state for "Normal" in `lg_thinq` (#142453) * Use common state for "Normal" in lg_thinq` * Replace internal references with common ones --- homeassistant/components/lg_thinq/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 767c984da3a..525a594f748 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -303,7 +303,7 @@ "state": { "invalid": "Invalid", "weak": "Weak", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "strong": "Strong", "very_strong": "Very strong" } @@ -607,7 +607,7 @@ "intensive_dry": "Spot", "macro": "Custom mode", "mop": "Mop", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "quiet_humidity": "Silent", "rapid_humidity": "Jet", @@ -626,7 +626,7 @@ "auto": "Low power", "high": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", "mop": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::mop%]", - "normal": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::normal%]", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]" } @@ -653,7 +653,7 @@ "heavy": "Intensive", "delicate": "Delicate", "turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]", - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "rinse": "Rinse", "refresh": "Refresh", "express": "Express", From 04fa69949890b6f678d86515f9e217f0999acd74 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 15:37:17 +0200 Subject: [PATCH 202/217] Use common states for "Low" and "High" in `fyta` (#142472) --- homeassistant/components/fyta/strings.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 1a25f654e19..f595b66ee37 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -79,9 +79,9 @@ "state": { "no_data": "No data", "too_low": "Too low", - "low": "Low", + "low": "[%key:common::state::low%]", "perfect": "Perfect", - "high": "High", + "high": "[%key:common::state::high%]", "too_high": "Too high" } }, @@ -90,9 +90,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -101,9 +101,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -112,9 +112,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, @@ -123,9 +123,9 @@ "state": { "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "low": "[%key:common::state::low%]", "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "high": "[%key:common::state::high%]", "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, From bf003d643cec2222b9cc96b11db8be369c01f935 Mon Sep 17 00:00:00 2001 From: Wilfred Ketelaar Date: Mon, 7 Apr 2025 15:54:08 +0200 Subject: [PATCH 203/217] Fixed Renault charge state icon (#142478) Fixed charge state icon (duplicate mdi prefix) --- homeassistant/components/renault/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index 8b9c4885eaa..aa9175052fb 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -35,7 +35,7 @@ }, "sensor": { "charge_state": { - "default": "mdi:mdi:flash-off", + "default": "mdi:flash-off", "state": { "charge_in_progress": "mdi:flash" } From 79b984d612f3ef74c3044653a121801eac56749b Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:25:00 +0800 Subject: [PATCH 204/217] Add switchbot roller shade and hubmini matter support (#142168) * Add roller shade and hubmini matter support * add unit tests * fix adv data --- .../components/switchbot/__init__.py | 7 + homeassistant/components/switchbot/const.py | 4 + homeassistant/components/switchbot/cover.py | 84 +++++++++ tests/components/switchbot/__init__.py | 50 ++++++ tests/components/switchbot/test_cover.py | 167 +++++++++++++++++- tests/components/switchbot/test_sensor.py | 47 +++++ 6 files changed, 358 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 09bc157d4d2..73b7307aa2d 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -66,6 +66,12 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.REMOTE.value: [Platform.SENSOR], + SupportedModels.ROLLER_SHADE.value: [ + Platform.COVER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -80,6 +86,7 @@ CLASS_BY_DEVICE = { SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, + SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 16b41d75541..787c1fa720b 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -35,6 +35,8 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" REMOTE = "remote" + ROLLER_SHADE = "roller_shade" + HUBMINI_MATTER = "hubmini_matter" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -51,6 +53,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.HUB2: SupportedModels.HUB2, SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM, SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, + SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -62,6 +65,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, SwitchbotModel.REMOTE: SupportedModels.REMOTE, + SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 5a9613ab2a2..bb73339aa05 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -37,6 +37,8 @@ async def async_setup_entry( coordinator = entry.runtime_data if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): async_add_entities([SwitchBotBlindTiltEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade): + async_add_entities([SwitchBotRollerShadeEntity(coordinator)]) else: async_add_entities([SwitchBotCurtainEntity(coordinator)]) @@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] self.async_write_ha_state() + + +class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotRollerShade + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + _attr_translation_key = "cover" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """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: + return + + self._attr_current_cover_position = last_state.attributes.get( + ATTR_CURRENT_POSITION + ) + self._last_run_success = last_state.attributes.get("last_run_success") + if self._attr_current_cover_position is not None: + self._attr_is_closed = self._attr_current_cover_position <= 20 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the roller shade.""" + + _LOGGER.debug("Switchbot to open roller shade %s", self._address) + self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the roller shade.""" + + _LOGGER.debug("Switchbot to close roller shade %s", self._address) + self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of roller shade.""" + + _LOGGER.debug("Switchbot to stop roller shade %s", self._address) + self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + + position = kwargs.get(ATTR_POSITION) + _LOGGER.debug("Switchbot to move at %d %s", position, self._address) + self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() + self._attr_current_cover_position = self.parsed_data["position"] + self._attr_is_closed = self.parsed_data["position"] <= 20 + + self.async_write_ha_state() diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 715073aa891..f57c8c107b2 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -386,3 +386,53 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +HUBMINI_MATTER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"%\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "HubMini Matter"), + time=0, + connectable=True, + tx_power=-127, +) + + +ROLLER_SHADE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RollerShade"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 8810963f63d..b52436f1932 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -24,7 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from . import WOBLINDTILT_SERVICE_INFO, WOCURTAIN3_SERVICE_INFO, make_advertisement +from . import ( + ROLLER_SHADE_SERVICE_INFO, + WOBLINDTILT_SERVICE_INFO, + WOCURTAIN3_SERVICE_INFO, + make_advertisement, +) from tests.common import MockConfigEntry, mock_restore_cache from tests.components.bluetooth import inject_bluetooth_service_info @@ -325,3 +330,163 @@ async def test_blindtilt_controlling( state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + +async def test_roller_shade_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the RollerShade.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 60}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_roller_shade_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Roller Shade controlling.""" + inject_bluetooth_service_info(hass, ROLLER_SHADE_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + entry.add_to_hass(hass) + info = {"battery": 39} + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b",\x00'\x9f\x11\x04" + + # Test open + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\xa0\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 68 + + # Test close + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5a\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 10 + + # Test stop + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5f\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 5 + + # Test set position + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x32\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 5fd270b3393..72ec3a8c727 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, @@ -293,3 +294,49 @@ async def test_hub2_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hubmini_matter_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for HubMini Matter.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hubmini_matter", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 3 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "24.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From cd2313d2ca87bff8da6b05fe8163cf5b82e87206 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Mon, 7 Apr 2025 18:16:44 +0200 Subject: [PATCH 205/217] Add tests to MotionMount integration (#137540) * Add entity tests * Add __init__ tests * Cleanup * Rename mock_motionmount_config_flow to mock_motionmount * Remove unneeded PropertyMock * Set defaults on mock_motionmount * Test proper device is created * Check whether proper device is created from test_init.py, also without mac * Find callback and use that to update name --- .../components/motionmount/select.py | 1 + tests/components/motionmount/__init__.py | 1 + tests/components/motionmount/conftest.py | 21 +- .../motionmount/test_config_flow.py | 220 +++++++----------- tests/components/motionmount/test_entity.py | 47 ++++ tests/components/motionmount/test_init.py | 129 ++++++++++ tests/components/motionmount/test_sensor.py | 4 +- 7 files changed, 280 insertions(+), 143 deletions(-) create mode 100644 tests/components/motionmount/test_entity.py create mode 100644 tests/components/motionmount/test_init.py diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index a8fcc84f2ec..861faa319cd 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-preset" self._presets: list[motionmount.Preset] = [] + self._attr_current_option = None def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index 3b97c8aa7fe..b56b2c92678 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 +MAC = bytes.fromhex("c4dd57f8a55f") TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py index 49f624b5266..795495f4457 100644 --- a/tests/components/motionmount/conftest.py +++ b/tests/components/motionmount/conftest.py @@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT -from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME +from . import HOST, MAC, PORT, ZEROCONF_MAC, ZEROCONF_NAME from tests.common import MockConfigEntry @@ -24,6 +24,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_with_pin() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_PIN: 1234}, + unique_id=ZEROCONF_MAC, + ) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -34,12 +45,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_motionmount_config_flow() -> Generator[MagicMock]: +def mock_motionmount() -> Generator[MagicMock]: """Return a mocked MotionMount config flow.""" with patch( - "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + "homeassistant.components.motionmount.motionmount.MotionMount", autospec=True, ) as motionmount_mock: client = motionmount_mock.return_value + client.name = ZEROCONF_NAME + client.mac = MAC yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 1fa2715595d..f6c5e8d8cc3 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -35,10 +35,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_user_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() user_input = MOCK_USER_INPUT.copy() @@ -54,10 +54,10 @@ async def test_user_connection_error( async def test_user_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when an invalid hostname is provided.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() user_input = MOCK_USER_INPUT.copy() @@ -73,10 +73,10 @@ async def test_user_connection_error_invalid_hostname( async def test_user_timeout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() user_input = MOCK_USER_INPUT.copy() @@ -92,10 +92,10 @@ async def test_user_timeout_error( async def test_user_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() user_input = MOCK_USER_INPUT.copy() @@ -111,13 +111,11 @@ async def test_user_not_connected_error( async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") user_input = MOCK_USER_INPUT.copy() @@ -139,11 +137,11 @@ async def test_user_response_error_single_device_new_ce_old_pro( async def test_user_response_error_single_device_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -167,13 +165,13 @@ async def test_user_response_error_single_device_new_ce_new_pro( async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there are multiple devices.""" mock_config_entry.add_to_hass(hass) - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -190,14 +188,12 @@ async def test_user_response_error_multi_device_new_ce_new_pro( async def test_user_response_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) user_input = MOCK_USER_INPUT.copy() @@ -211,12 +207,8 @@ async def test_user_response_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,10 +228,10 @@ async def test_user_response_authentication_needed( async def test_zeroconf_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -255,10 +247,10 @@ async def test_zeroconf_connection_error( async def test_zeroconf_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -274,10 +266,10 @@ async def test_zeroconf_connection_error_invalid_hostname( async def test_zeroconf_timout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -293,10 +285,10 @@ async def test_zeroconf_timout_error( async def test_zeroconf_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -312,12 +304,10 @@ async def test_zeroconf_not_connected_error( async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) result = await hass.config_entries.flow.async_init( @@ -348,10 +338,10 @@ async def test_show_zeroconf_form_new_ce_old_pro( async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -383,7 +373,7 @@ async def test_show_zeroconf_form_new_ce_new_pro( async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test we abort zeroconf flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -402,13 +392,11 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -421,12 +409,8 @@ async def test_zeroconf_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -448,17 +432,13 @@ async def test_zeroconf_authentication_needed( async def test_authentication_incorrect_then_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -483,9 +463,7 @@ async def test_authentication_incorrect_then_correct_pin( assert result["errors"][CONF_PIN] == CONF_PIN # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -505,18 +483,14 @@ async def test_authentication_incorrect_then_correct_pin( async def test_authentication_first_incorrect_pin_to_backoff( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - side_effect=[True, 1] - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(side_effect=[True, 1]) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -532,7 +506,7 @@ async def test_authentication_first_incorrect_pin_to_backoff( user_input=MOCK_PIN_INPUT.copy(), ) - assert mock_motionmount_config_flow.authenticate.called + assert mock_motionmount.authenticate.called assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backoff" @@ -541,12 +515,8 @@ async def test_authentication_first_incorrect_pin_to_backoff( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -567,16 +537,14 @@ async def test_authentication_first_incorrect_pin_to_backoff( async def test_authentication_multiple_incorrect_pins( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) user_input = MOCK_USER_INPUT.copy() @@ -602,12 +570,8 @@ async def test_authentication_multiple_incorrect_pins( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -628,16 +592,14 @@ async def test_authentication_multiple_incorrect_pins( async def test_authentication_show_backoff_when_still_running( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -671,12 +633,8 @@ async def test_authentication_show_backoff_when_still_running( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -697,17 +655,13 @@ async def test_authentication_show_backoff_when_still_running( async def test_authentication_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -720,9 +674,7 @@ async def test_authentication_correct_pin( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -741,11 +693,11 @@ async def test_authentication_correct_pin( async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -773,11 +725,11 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full zeroconf flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -808,7 +760,7 @@ async def test_full_zeroconf_flow_implementation( async def test_full_reauth_flow_implementation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test reauthentication.""" mock_config_entry.add_to_hass(hass) @@ -824,12 +776,8 @@ async def test_full_reauth_flow_implementation( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), diff --git a/tests/components/motionmount/test_entity.py b/tests/components/motionmount/test_entity.py new file mode 100644 index 00000000000..e335c3a913b --- /dev/null +++ b/tests/components/motionmount/test_entity.py @@ -0,0 +1,47 @@ +"""Tests for the MotionMount Entity base.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import format_mac + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +async def test_entity_rename( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = True + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == ZEROCONF_NAME + + # Simulate the user changed the name of the device + mock_motionmount.name = "Blub" + + for callback in mock_motionmount.add_listener.call_args_list: + callback[0][0]() + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == "Blub" diff --git a/tests/components/motionmount/test_init.py b/tests/components/motionmount/test_init.py new file mode 100644 index 00000000000..e307945d0d0 --- /dev/null +++ b/tests/components/motionmount/test_init.py @@ -0,0 +1,129 @@ +"""Tests for the MotionMount init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.motionmount import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + + +async def test_setup_entry_with_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_without_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x00" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_failed_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.connect.side_effect = TimeoutError() + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x01" + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, sources={SOURCE_REAUTH})) + + +async def test_setup_entry_wrong_pin( + hass: HomeAssistant, + mock_config_entry_with_pin: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry_with_pin.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup( + mock_config_entry_with_pin.entry_id + ) + + assert mock_config_entry_with_pin.state is ConfigEntryState.SETUP_ERROR + assert any( + mock_config_entry_with_pin.async_get_active_flows(hass, sources={SOURCE_REAUTH}) + ) + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Test entries are unloaded correctly.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_motionmount.disconnect.call_count == 1 diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index 0320e62d640..0132860727f 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -7,12 +7,10 @@ import pytest from homeassistant.core import HomeAssistant -from . import ZEROCONF_NAME +from . import MAC, ZEROCONF_NAME from tests.common import MockConfigEntry -MAC = bytes.fromhex("c4dd57f8a55f") - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( From f2e4bcea1911578f13ded85025d8a62b27d6b022 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 7 Apr 2025 18:24:07 +0200 Subject: [PATCH 206/217] Add subdiv aliases to workday (#133608) * Add subdiv aliases to workday * Fix * Add lib test --- .../components/workday/config_flow.py | 12 ++++- tests/components/workday/test_config_flow.py | 45 ++++++++++++++++++- tests/components/workday/test_init.py | 16 +++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 895c7cd50e2..b0b1e9fcc02 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -79,10 +80,19 @@ def add_province_and_language_to_schema( } if provinces := all_countries.get(country): + if _country.subdivisions_aliases and ( + subdiv_aliases := _country.get_subdivision_aliases() + ): + province_options: list[Any] = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces province_schema = { vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=provinces, + options=province_options, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 51d4b899d25..c05da654f96 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -55,7 +55,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_LANGUAGE: "de", + CONF_LANGUAGE: "en_US", }, ) await hass.async_block_till_done() @@ -70,7 +70,48 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "language": "de", + "language": "en_US", + } + + +async def test_form_province_no_alias(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "US", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "US", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], } diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 1e0c9cbebc6..2735175b49b 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from freezegun.api import FrozenDateTimeFactory +from holidays.utils import country_holidays from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -50,3 +51,18 @@ async def test_update_options( assert entry_check.state is ConfigEntryState.LOADED state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "off" + + +async def test_workday_subdiv_aliases() -> None: + """Test subdiv aliases in holidays library.""" + + country = country_holidays( + country="FR", + years=2025, + ) + subdiv_aliases = country.get_subdivision_aliases() + assert subdiv_aliases["GES"] == [ # codespell:ignore + "Alsace", + "Champagne-Ardenne", + "Lorraine", + ] From a787c6a31e3690cef45e14542401699957436f03 Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 7 Apr 2025 18:53:35 +0200 Subject: [PATCH 207/217] Add state multiplexer in fibaro integration (#139649) * Add state multiplexer in fibaro integration * Add unload test * Adjust code comments * Add event entity test * . --- homeassistant/components/fibaro/__init__.py | 86 ++++----------------- homeassistant/components/fibaro/entity.py | 8 +- homeassistant/components/fibaro/event.py | 11 ++- tests/components/fibaro/conftest.py | 21 +++++ tests/components/fibaro/test_event.py | 35 +++++++++ tests/components/fibaro/test_init.py | 31 ++++++++ 6 files changed, 118 insertions(+), 74 deletions(-) create mode 100644 tests/components/fibaro/test_event.py create mode 100644 tests/components/fibaro/test_init.py diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index a4f59d8ab76..88288a86b59 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -14,9 +14,10 @@ from pyfibaro.fibaro_client import ( ) from pyfibaro.fibaro_data_helper import read_rooms from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_device_manager import FibaroDeviceManager from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel -from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver +from pyfibaro.fibaro_state_resolver import FibaroEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform @@ -81,8 +82,8 @@ class FibaroController: self._client = fibaro_client self._fibaro_info = info - # Whether to import devices from plugins - self._import_plugins = import_plugins + # The fibaro device manager exposes higher level API to access fibaro devices + self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins) # Mapping roomId to room object self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object @@ -91,79 +92,30 @@ class FibaroController: ) # List of devices by entity platform # All scenes self._scenes = self._client.read_scenes() - self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId - # Event callbacks by device id - self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} # Unique serial number of the hub self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} self._read_devices() - def enable_state_handler(self) -> None: - """Start StateHandler thread for monitoring updates.""" - self._client.register_update_handler(self._on_state_change) + def disconnect(self) -> None: + """Close push channel.""" + self._fibaro_device_manager.close() - def disable_state_handler(self) -> None: - """Stop StateHandler thread used for monitoring updates.""" - self._client.unregister_update_handler() - - def _on_state_change(self, state: Any) -> None: - """Handle change report received from the HomeCenter.""" - callback_set = set() - for change in state.get("changes", []): - try: - dev_id = change.pop("id") - if dev_id not in self._device_map: - continue - device = self._device_map[dev_id] - for property_name, value in change.items(): - if property_name == "log": - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", device.friendly_name, value) - continue - if property_name == "logTemp": - continue - if property_name in device.properties: - device.properties[property_name] = value - _LOGGER.debug( - "<- %s.%s = %s", device.ha_id, property_name, str(value) - ) - else: - _LOGGER.warning("%s.%s not found", device.ha_id, property_name) - if dev_id in self._callbacks: - callback_set.add(dev_id) - except (ValueError, KeyError): - pass - for item in callback_set: - for callback in self._callbacks[item]: - callback() - - resolver = FibaroStateResolver(state) - for event in resolver.get_events(): - # event does not always have a fibaro id, therefore it is - # essential that we first check for relevant event type - if ( - event.event_type.lower() == "centralsceneevent" - and event.fibaro_id in self._event_callbacks - ): - for callback in self._event_callbacks[event.fibaro_id]: - callback(event) - - def register(self, device_id: int, callback: Any) -> None: + def register( + self, device_id: int, callback: Callable[[DeviceModel], None] + ) -> Callable[[], None]: """Register device with a callback for updates.""" - device_callbacks = self._callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_change_listener(device_id, callback) def register_event( self, device_id: int, callback: Callable[[FibaroEvent], None] - ) -> None: + ) -> Callable[[], None]: """Register device with a callback for central scene events. The callback receives one parameter with the event. """ - device_callbacks = self._event_callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_event_listener(device_id, callback) def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" @@ -286,7 +238,7 @@ class FibaroController: def _read_devices(self) -> None: """Read and process the device list.""" - devices = self._client.read_devices() + devices = self._fibaro_device_manager.get_devices() self._device_map = {} last_climate_parent = None last_endpoint = None @@ -301,9 +253,8 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) - platform = None - if device.enabled and (not device.is_plugin or self._import_plugins): - platform = self._map_device_to_platform(device) + + platform = self._map_device_to_platform(device) if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" @@ -393,8 +344,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - controller.enable_state_handler() - return True @@ -403,8 +352,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - entry.runtime_data.disable_state_handler() - + entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 5375b058315..e8ed5afc500 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -36,9 +36,13 @@ class FibaroEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) + self.async_on_remove( + self.controller.register( + self.fibaro_device.fibaro_id, self._update_callback + ) + ) - def _update_callback(self) -> None: + def _update_callback(self, fibaro_device: DeviceModel) -> None: """Update the state.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index 0beea2e336e..ad44719c8be 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity): await super().async_added_to_hass() # Register event callback - self.controller.register_event( - self.fibaro_device.fibaro_id, self._event_callback + self.async_on_remove( + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) ) def _event_callback(self, event: FibaroEvent) -> None: - if event.key_id == self._button: + if ( + event.event_type.lower() == "centralsceneevent" + and event.key_id == self._button + ): self._trigger_event(event.key_event_type) self.schedule_update_ha_state() diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 55b7e35132c..9e7c2f6c003 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from pyfibaro.fibaro_device import SceneEvent import pytest from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN @@ -231,6 +232,26 @@ def mock_fan_device() -> Mock: return climate +@pytest.fixture +def mock_button_device() -> Mock: + """Fixture for a button device.""" + climate = Mock() + climate.fibaro_id = 8 + climate.parent_fibaro_id = 0 + climate.name = "Test button" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.remoteController" + climate.base_type = "com.fibaro.actor" + climate.properties = {"manufacturer": ""} + climate.central_scene_event = [SceneEvent(1, "Pressed")] + climate.actions = {} + climate.interfaces = ["zwaveCentralScene"] + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_event.py b/tests/components/fibaro/test_event.py new file mode 100644 index 00000000000..ced39b71197 --- /dev/null +++ b/tests/components/fibaro/test_event.py @@ -0,0 +1,35 @@ +"""Test the Fibaro event platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_button_device: Mock, + mock_room: Mock, +) -> None: + """Test that the button device creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_button_device] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.EVENT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("event.room_1_test_button_8_button_1") + assert entry + assert entry.unique_id == "hc2_111111.8.1" + assert entry.original_name == "Room 1 Test button Button 1" diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py new file mode 100644 index 00000000000..330de74d6af --- /dev/null +++ b/tests/components/fibaro/test_init.py @@ -0,0 +1,31 @@ +"""Test init methods.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_unload_integration( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test unload integration stops state listener.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + # Assert + assert mock_fibaro_client.unregister_update_handler.call_count == 1 From 4813b5c88251e1f173cb2ed6d53ec0c309cc46a7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:16:48 +0200 Subject: [PATCH 208/217] Fix wait for a dependency with config entries (#142318) * Fix wait for dependency with config entries * test types * test coverage --------- Co-authored-by: J. Nick Koston --- homeassistant/setup.py | 37 ++++++++++++---------- tests/test_setup.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index aeaea1146a1..76061b72b73 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -202,16 +202,19 @@ async def _async_process_dependencies( """ setup_futures = hass.data.setdefault(DATA_SETUP, {}) - dependencies_tasks = { - dep: setup_futures.get(dep) - or create_eager_task( - async_setup_component(hass, dep, config), - name=f"setup {dep} as dependency of {integration.domain}", - loop=hass.loop, - ) - for dep in integration.dependencies - if dep not in hass.config.components - } + dependencies_tasks: dict[str, asyncio.Future[bool]] = {} + + for dep in integration.dependencies: + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) # We don't want to just wait for the futures from `to_be_loaded` here. @@ -219,16 +222,18 @@ async def _async_process_dependencies( # scheduled to be set up, as if for whatever reason they had not been, # we would deadlock waiting for them here. for dep in integration.after_dependencies: - if ( - dep not in dependencies_tasks - and dep in to_be_loaded - and dep not in hass.config.components - ): - dependencies_tasks[dep] = setup_futures.get(dep) or create_eager_task( + if dep not in to_be_loaded or dep in dependencies_tasks: + continue + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( async_setup_component(hass, dep, config), name=f"setup {dep} as after dependency of {integration.domain}", loop=hass.loop, ) + dependencies_tasks[dep] = fut if not dependencies_tasks: return [] diff --git a/tests/test_setup.py b/tests/test_setup.py index 1f0e668d4e2..084b657a2f2 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -353,6 +353,76 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert await setup.async_setup_component(hass, "comp2", {}) +async def test_component_not_setup_already_setup_dependencies( + hass: HomeAssistant, +) -> None: + """Test we do not set up component dependencies if they are already set up.""" + mock_integration( + hass, + MockModule( + "comp", + dependencies=["dep1"], + partial_manifest={"after_dependencies": ["dep2"]}, + ), + ) + mock_integration(hass, MockModule("dep1")) + mock_integration(hass, MockModule("dep2")) + + setup.async_set_domains_to_be_loaded(hass, {"comp", "dep2"}) + + hass.config.components.add("dep1") + hass.config.components.add("dep2") + + with patch( + "homeassistant.setup.async_setup_component", + side_effect=setup.async_setup_component, + ) as mock_setup: + await mock_setup(hass, "comp", {}) + + assert mock_setup.call_count == 1 + + +@pytest.mark.usefixtures("mock_handlers") +async def test_component_setup_dependencies_with_config_entry( + hass: HomeAssistant, +) -> None: + """Test we wait for a dependency with config entry.""" + calls: list[str] = [] + + async def mock_async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await asyncio.sleep(0) + calls.append("entry") + return True + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_async_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + MockConfigEntry(domain="comp").add_to_hass(hass) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + calls.append("comp") + return True + + mock_integration( + hass, + MockModule("comp2", dependencies=["comp"], async_setup=mock_async_setup), + ) + mock_integration( + hass, + MockModule("comp3", dependencies=["comp"], async_setup=mock_async_setup), + ) + + await asyncio.gather( + setup.async_setup_component(hass, "comp2", {}), + setup.async_setup_component(hass, "comp3", {}), + ) + + assert "comp" in hass.config.components + assert "comp2" in hass.config.components + assert "comp3" in hass.config.components + + assert calls == ["entry", "comp", "comp"] + + async def test_component_failing_setup(hass: HomeAssistant) -> None: """Test component that fails setup.""" mock_integration(hass, MockModule("comp", setup=lambda hass, config: False)) From 7ad13c8897eee0c09ab2111a61483cb598cdc2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 7 Apr 2025 20:23:38 +0200 Subject: [PATCH 209/217] Delete Home Connect deprecated binary door sensor (#142490) --- .../components/home_connect/binary_sensor.py | 105 +-------- .../home_connect/test_binary_sensor.py | 200 ------------------ 2 files changed, 2 insertions(+), 303 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index a28b4ff2b49..7e4523201f9 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -5,37 +5,18 @@ from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from .common import setup_home_connect_entry -from .const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, - DOMAIN, - REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_OPEN, -) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity PARALLEL_UPDATES = 0 @@ -173,8 +154,6 @@ def _get_entities_for_appliance( for description in BINARY_SENSORS if description.key in appliance.status ) - if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: - entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) return entities @@ -220,83 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity) def available(self) -> bool: """Return the availability.""" return self.coordinator.last_update_success - - -class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): - """Binary sensor for Home Connect Generic Door.""" - - _attr_has_entity_name = False - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, - appliance, - HomeConnectBinarySensorEntityDescription( - key=StatusKey.BSH_COMMON_DOOR_STATE, - device_class=BinarySensorDeviceClass.DOOR, - boolean_map={ - BSH_DOOR_STATE_CLOSED: False, - BSH_DOOR_STATE_LOCKED: False, - BSH_DOOR_STATE_OPEN: True, - }, - entity_registry_enabled_default=False, - ), - ) - self._attr_unique_id = f"{appliance.info.ha_id}-Door" - self._attr_name = f"{appliance.info.name} Door" - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_common_door_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_common_door_sensor", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" - ) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index ce879a38de5..a245372c247 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,7 +1,6 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -15,17 +14,11 @@ from aiohomeconnect.model import ( from aiohomeconnect.model.error import HomeConnectApiError import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -36,11 +29,8 @@ from homeassistant.const import ( ) 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 tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator @pytest.fixture @@ -179,7 +169,6 @@ async def test_binary_sensors_entity_availability( ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ - "binary_sensor.washer_door", "binary_sensor.washer_remote_control", ] assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -222,57 +211,6 @@ async def test_binary_sensors_entity_availability( assert state.state != STATE_UNAVAILABLE -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) -@pytest.mark.parametrize( - ("value", "expected"), - [ - (BSH_DOOR_STATE_CLOSED, "off"), - (BSH_DOOR_STATE_LOCKED, "off"), - (BSH_DOOR_STATE_OPEN, "on"), - ("", STATE_UNKNOWN), - ], -) -async def test_binary_sensors_door_states( - appliance: HomeAppliance, - expected: str, - value: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Tests for Appliance door states.""" - entity_id = "binary_sensor.washer_door" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.STATUS, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, - raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, - timestamp=0, - level="", - handling="", - value=value, - ) - ], - ), - ) - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) - - @pytest.mark.parametrize( ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ @@ -403,141 +341,3 @@ async def test_connected_sensor_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_door_binary_sensor_deprecation_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_door_binary_sensor_deprecation_issue_fix( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 From 4ccd30865b1700b4e37b97f126276be14dfa71f9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 20:24:18 +0200 Subject: [PATCH 210/217] Use common state for "Normal" in `humidifier` (#142479) --- homeassistant/components/humidifier/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index abd9ca5757b..361636eadc6 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -62,7 +62,7 @@ "mode": { "name": "Mode", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "home": "[%key:common::state::home%]", "away": "[%key:common::state::not_home%]", "auto": "Auto", From 5c2f19de8807a69605f9e34018b6f57aaae53753 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 20:24:43 +0200 Subject: [PATCH 211/217] Use common states for "Normal" and "High" in `romy` (#142485) Also reordered the lines a bit for grouping the common states. --- homeassistant/components/romy/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json index 78721da17ba..b8725624ac7 100644 --- a/homeassistant/components/romy/strings.json +++ b/homeassistant/components/romy/strings.json @@ -36,11 +36,11 @@ "fan_speed": { "state": { "default": "Default", - "normal": "Normal", - "silent": "Silent", + "normal": "[%key:common::state::normal%]", + "high": "[%key:common::state::high%]", "intensive": "Intensive", + "silent": "Silent", "super_silent": "Super silent", - "high": "High", "auto": "Auto" } } From 19a39a3647d2535c28d520e934c4ee8e7455a2ed Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 20:27:01 +0200 Subject: [PATCH 212/217] Use common state for "Normal" in `homee` (#142450) * Use common state for "Normal" in `homee` Also capitalize the brand name in one string. * Change all occurrences of "homee" to lower-case --- homeassistant/components/homee/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 623a4e93895..806a21556cb 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Homee {name} ({host})", + "flow_title": "homee {name} ({host})", "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, @@ -18,9 +18,9 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The IP address of your Homee.", - "username": "The username for your Homee.", - "password": "The password for your Homee." + "host": "The IP address of your homee.", + "username": "The username for your homee.", + "password": "The password for your homee." } } } @@ -45,7 +45,7 @@ "load_alarm": { "name": "Load", "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Overload" } }, @@ -352,7 +352,7 @@ }, "exceptions": { "connection_closed": { - "message": "Could not connect to Homee while setting attribute." + "message": "Could not connect to homee while setting attribute." } } } From 1cedacc39500bd092ae798abe976d390f8a9a177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 7 Apr 2025 21:03:01 +0200 Subject: [PATCH 213/217] Delete deprecated strings related to Home Connect binary door sensor (#142495) Delete deprecated strings related to binary door sensor --- homeassistant/components/home_connect/strings.json | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index dfbe1ca26fe..5b52183fccf 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -132,17 +132,6 @@ } } }, - "deprecated_binary_common_door_sensor": { - "title": "Deprecated binary door sensor detected in some automations or scripts", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]", - "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." - } - } - } - }, "deprecated_command_actions": { "title": "The command related actions are deprecated in favor of the new buttons", "fix_flow": { From 8f3f8fa35fa6d811163a33e8d5c857a5a2c6f937 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Apr 2025 21:06:11 +0200 Subject: [PATCH 214/217] Make spelling of "ecobee" consistent, matching official branding (#142496) --- homeassistant/components/ecobee/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 078643ee789..bc61cb444c1 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -55,7 +55,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to create the vacation." + "description": "ecobee thermostat on which to create the vacation." }, "vacation_name": { "name": "Vacation name", @@ -101,7 +101,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to delete the vacation." + "description": "ecobee thermostat on which to delete the vacation." }, "vacation_name": { "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", @@ -149,7 +149,7 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa microphone (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", @@ -177,7 +177,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to set active sensors." + "description": "ecobee thermostat on which to set active sensors." }, "preset_mode": { "name": "Climate Name", @@ -203,12 +203,12 @@ }, "issues": { "migrate_aux_heat": { - "title": "Migration of Ecobee set_aux_heat action", + "title": "Migration of ecobee set_aux_heat action", "fix_flow": { "step": { "confirm": { - "description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy Ecobee set_aux_heat action" + "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy ecobee set_aux_heat action" } } } From c6ac8780ca0a0c1d6f61c24260d91dee72e09090 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:56:21 +0200 Subject: [PATCH 215/217] Fix kelvin parameter in light action specifications (#142456) --- homeassistant/components/light/services.yaml | 4 ++-- homeassistant/components/light/strings.json | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index c59d9e22483..2cd5921d794 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -199,7 +199,7 @@ turn_on: example: "[255, 100, 100]" selector: color_rgb: - kelvin: &kelvin + color_temp_kelvin: &color_temp_kelvin filter: *color_temp_support selector: color_temp: @@ -316,7 +316,7 @@ toggle: fields: transition: *transition rgb_color: *rgb_color - kelvin: *kelvin + color_temp_kelvin: *color_temp_kelvin brightness_pct: *brightness_pct effect: *effect advanced_fields: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index d4b709f65aa..7a53f2569e7 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -19,8 +19,8 @@ "field_flash_name": "Flash", "field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.", "field_hs_color_name": "Hue/Sat color", - "field_kelvin_description": "Color temperature in Kelvin.", - "field_kelvin_name": "Color temperature", + "field_color_temp_kelvin_description": "Color temperature in Kelvin.", + "field_color_temp_kelvin_name": "Color temperature", "field_profile_description": "Name of a light profile to use.", "field_profile_name": "Profile", "field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.", @@ -328,9 +328,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", @@ -426,9 +426,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", From c14380247b05020f110e5f0b3942e663d34b0657 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Mon, 7 Apr 2025 21:20:54 -0700 Subject: [PATCH 216/217] Handle None on the response candidates in Google Generative AI (#142497) * Added type checking on the candidates list * Made error message a constant --- .../conversation.py | 14 +++++++-- .../test_conversation.py | 29 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 7c19c5445a7..73a82b98664 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -55,6 +55,10 @@ from .const import ( # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 +ERROR_GETTING_RESPONSE = ( + "Sorry, I had a problem getting a response from Google Generative AI." +) + async def async_setup_entry( hass: HomeAssistant, @@ -429,6 +433,12 @@ class GoogleGenerativeAIConversationEntity( raise HomeAssistantError( f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}" ) + if not chat_response.candidates: + LOGGER.error( + "No candidates found in the response: %s", + chat_response, + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) except ( APIError, @@ -452,9 +462,7 @@ class GoogleGenerativeAIConversationEntity( response_parts = chat_response.candidates[0].content.parts if not response_parts: - raise HomeAssistantError( - "Sorry, I had a problem getting a response from Google Generative AI." - ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) content = " ".join( [part.text.strip() for part in response_parts if part.text] ) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 9c4ecc4f9a4..75cb308d5de 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import UserContent, async_get_chat_log, trace from homeassistant.components.google_generative_ai_conversation.conversation import ( + ERROR_GETTING_RESPONSE, _escape_decode, _format_schema, ) @@ -492,7 +493,33 @@ async def test_empty_response( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Sorry, I had a problem getting a response from Google Generative AI." + ERROR_GETTING_RESPONSE + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_none_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test empty response.""" + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = None + result = await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + ERROR_GETTING_RESPONSE ) From 8dee5851d201dbde70632f2c05e7abecfec658d7 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Tue, 8 Apr 2025 17:50:36 +1200 Subject: [PATCH 217/217] Add jaraco.itertools license exception as the classifier was removed but no SPDX expression was added (#142439) --- script/licenses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/licenses.py b/script/licenses.py index 448e9dd2a67..62e1845b911 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -190,6 +190,7 @@ EXCEPTIONS = { "enocean", # https://github.com/kipe/enocean/pull/142 "imutils", # https://github.com/PyImageSearch/imutils/pull/292 "iso4217", # Public domain + "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 "kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6 "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48